fix(ui): a dialog opens focused where it said it would, and the wizard is a dialog
Deep-review round 5, over round 4's own fixes. - Round 4 gave `ui/Modal` a persistent portal host so a fullscreen transition would stop tearing the dialog down. The host was attached in a `useLayoutEffect` — one commit phase AFTER React runs `autoFocus` in `commitMount`, and `focus()` on a disconnected node is a no-op. Every autoFocus in every dialog was dead: Mod+K opened the command palette with the focus on the ✕, so typing searched nothing and Enter closed it; the same for the download rename field and every non-danger confirm. The host is attached in a `useInsertionEffect` now, a whole phase earlier, and the whole re-parent — including the focus save/restore across the move, which `appendChild` needs because it is remove-then-insert — lives in one `useFullscreenPortalHost`. `ui/AnchoredPopover` had a second copy of that re-parent WITHOUT the focus restore, and a comment claiming focus survived it; both now come from the hook. - `ui/Toaster` classified a toast raised in the SAME commit as a host takeover among the ones it inherits, because it read the store live at the takeover render. A dialog that notifies and closes in one handler (UploadDialog) therefore put its toast up with no role, into a region still `aria-live="off"` — announced by neither path. "Inherited" is now what the outgoing host last had on screen, and a toast that is NOT inherited is announced even inside the unarmed window. - The onboarding wizard renders its own shell, and Escape ordering was all of the dialog contract it had: no role, no label, no initial focus, no trap, no restore. A keyboard user tabbed out of the first-login wizard into the app behind the scrim. The focus half of the contract is now one shared `ui/dialogFocus` that both `ui/Modal` and the wizard use. Every fix has a regression test, each mutation-checked (6 mutations, all red on the assertion they are named after).
This commit is contained in:
@@ -41,6 +41,9 @@ const topHost = () => {
|
||||
return top?.id ?? null;
|
||||
};
|
||||
|
||||
// What the host that is currently top last rendered, for the next host to inherit. See `inherited`.
|
||||
let lastTopIds: number[] = [];
|
||||
|
||||
export default function Toaster({
|
||||
overlay = false,
|
||||
order,
|
||||
@@ -69,10 +72,19 @@ export default function Toaster({
|
||||
// LATER task. Not in an effect: React flushes those in the same task as the render that inserted the
|
||||
// toasts, and a screen reader reads the region's state after the task — by which time it was polite
|
||||
// again, i.e. the arming did nothing.
|
||||
const inherited = useMemo(
|
||||
() => new Set(isTop ? getActiveToasts().map((n) => n.id) : []),
|
||||
[isTop],
|
||||
);
|
||||
//
|
||||
// "Inherited" is what the PREVIOUS top host last had on screen — not whatever is in the store at
|
||||
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
|
||||
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
|
||||
// toast up in the very batch that handed the app host the top spot, so it was classified as
|
||||
// inherited and announced by neither path (deep-review round 5). Written during render on purpose
|
||||
// — the handover must be readable by the taking-over host in the SAME commit, and an effect
|
||||
// cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
|
||||
// host's last screen either way.
|
||||
const inherited = useMemo(() => new Set(isTop ? lastTopIds : []), [isTop]);
|
||||
// AFTER the memo above has read the outgoing host's screen — this host's own first render as top
|
||||
// would otherwise overwrite the handover with the toasts it is about to classify.
|
||||
if (isTop) lastTopIds = toasts.map((n) => n.id);
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isTop) {
|
||||
@@ -110,7 +122,13 @@ export default function Toaster({
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
role={urgent && !inherited.has(toast.id) ? "alert" : undefined}
|
||||
// A toast this host did not inherit must be announced even in the unarmed window, where
|
||||
// the region is still `off`: urgent ones assertively, the rest with their own polite
|
||||
// `role="status"` (single-element, so its implied atomic re-read is just this toast).
|
||||
// Once armed, the region itself does the announcing and the role comes back off.
|
||||
role={
|
||||
inherited.has(toast.id) ? undefined : urgent ? "alert" : armed ? undefined : "status"
|
||||
}
|
||||
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
|
||||
Reference in New Issue
Block a user