fix(ui): a dialog keeps the focus it was given, and hands it back where it took it from
Round 5 extracted `ui/dialogFocus` so no shell would hand-roll the focus contract, then introduced four ways to lose the focus anyway — all four measured in jsdom against the real components. **Ownership was narrower than the trap.** `ui/Modal` traps Tab over the whole dialog on purpose, so a toast's Undo or ✕ stays reachable; ownership was tracked over the panel alone. Tabbing to that ✕ therefore read as "the user left the dialog", and since removing a toast fires no `focusin` to re-arm, closing afterwards skipped the restore and left the focus on <body>. Before this hook existed the restore was unconditional, so it was a regression. **A dialog stacked above us is still ours.** A nested `ui/Modal` portals into its own host, so it is outside the outer dialog's root however wide that scope is drawn — widening alone could not fix it. Both closing in one commit left the focus on <body>: the outer skipped its restore, and the child's own target (a control inside the outer panel) was already detached, because passive cleanups run parent-first. Ownership now means "inside my dialog, or inside a modal layer above it", which is what the rule always intended. **The wizard disagreed with `ui/Modal` on its first line.** `trapTab(e, panel)`, with neither the dialog-root scope (so its own toast host was outside the cycle — the connection notice never auto-dismisses, and its ✕ was unreachable from the keyboard) nor the `container` argument the JSDoc exists for: `panel.contains( panel)` is true, so focus sitting ON the tabIndex=-1 panel matched no branch and a Shift+Tab walked into the app behind the scrim. Both call sites now pass the same three arguments. **The toast handover was a render-phase read-modify-write.** A `useMemo` read a module global that the next line overwrote; React re-invokes a component body and every `useMemo` factory in it on a StrictMode double render, and the LAST invocation commits — so the second pass re-read what the first had just written, the takeover host classified its OWN new toast as inherited, and the dev build behaved exactly like the code round 5 replaced. Snapshotted through a ref keyed on `isTop`, so a repeated invocation of one render is a no-op. And the fourth `aria-modal` shell, `Welcome`'s screenshot lightbox, is finally swept into the same contract: it declared itself a modal dialog while leaving the focus on the thumbnail behind an 80%-black scrim and letting Tab walk the landing page underneath. The headline fix of the previous commit — StrictMode's re-mount taking the focus off an `autoFocus` field — had no test at all; deleting it left all 1009 green. It has one now. 9 tests, mutation-checked against 8 mutations, each red on the test that names it.
This commit is contained in:
@@ -47,8 +47,13 @@ export function trapTab(
|
||||
}
|
||||
|
||||
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
|
||||
* `scopeRef` is the panel the focus should land in. */
|
||||
export function useDialogChrome(scopeRef: RefObject<HTMLElement | null>): void {
|
||||
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
|
||||
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
|
||||
* disagree about what counts as "inside" (see `heldRef`). */
|
||||
export function useDialogChrome(
|
||||
scopeRef: RefObject<HTMLElement | null>,
|
||||
ownerRef?: RefObject<HTMLElement | null>,
|
||||
): void {
|
||||
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||
// dialog's autoFocus (or our own initial focus) and moves activeElement into the dialog. Reading it
|
||||
// later (in the effect) would capture a now-inside element that unmounts on close, stranding focus
|
||||
@@ -65,6 +70,13 @@ export function useDialogChrome(scopeRef: RefObject<HTMLElement | null>): void {
|
||||
// time a passive cleanup runs on a real unmount the panel is already out of the document and the
|
||||
// focus has fallen to <body>, so "does the panel contain activeElement" answers false for both a
|
||||
// close and a user who clicked away — and those two want opposite things.
|
||||
//
|
||||
// Measured against the DIALOG, not the panel. The two scopes must be the same one the Tab trap
|
||||
// uses, and `ui/Modal`'s trap deliberately spans the dialog root so an in-dialog toast's Undo/✕
|
||||
// stays reachable — so tabbing to that ✕ read as "the user left the dialog", and since removing
|
||||
// the toast fires no `focusin` to re-arm, closing the dialog afterwards stranded the focus on
|
||||
// <body> instead of handing it back to the trigger. Measured in jsdom, and a regression: before
|
||||
// this hook existed the restore was unconditional (deep-review round 6).
|
||||
const heldRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
@@ -82,9 +94,21 @@ export function useDialogChrome(scopeRef: RefObject<HTMLElement | null>): void {
|
||||
target.focus();
|
||||
}
|
||||
const onFocusIn = () => {
|
||||
const inside = scopeRef.current?.contains(document.activeElement) ?? false;
|
||||
heldRef.current = inside;
|
||||
if (inside) focusedRef.current = document.activeElement as HTMLElement;
|
||||
const owner = ownerRef?.current ?? scopeRef.current;
|
||||
const active = document.activeElement;
|
||||
// "Still ours" also means a dialog stacked ABOVE us. A nested `ui/Modal` portals into its own
|
||||
// host on <body>, so it is outside our root BY CONSTRUCTION — widening the scope cannot see
|
||||
// it — and while it held the focus we read that as the user walking away. Then both closing
|
||||
// together left the focus on <body>: ours skipped the restore, and the child's own
|
||||
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
|
||||
// and it is the ordinary case — an error dialog raised over a dialog (round 6).
|
||||
heldRef.current =
|
||||
(owner?.contains(active) ?? false) ||
|
||||
(active instanceof Element && active.closest('[aria-modal="true"]') != null);
|
||||
// Where to put the focus back on a re-mount, though: the PANEL's own last focus. A toast is
|
||||
// gone by then, so restoring onto it would strand the focus exactly as before.
|
||||
if (scopeRef.current?.contains(document.activeElement))
|
||||
focusedRef.current = document.activeElement as HTMLElement;
|
||||
};
|
||||
onFocusIn();
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
@@ -96,5 +120,5 @@ export function useDialogChrome(scopeRef: RefObject<HTMLElement | null>): void {
|
||||
// rudest thing a closing dialog can do.
|
||||
if (heldRef.current && restoreTo && restoreTo.isConnected) restoreTo.focus();
|
||||
};
|
||||
}, [restoreTo, scopeRef]);
|
||||
}, [restoreTo, scopeRef, ownerRef]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user