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:
@@ -118,14 +118,22 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
// into the app behind the scrim — where the focus ring is invisible (deep-review round 5). The trap
|
||||
// and the roles come from the same place `ui/Modal` gets them, not from a second hand-written copy.
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDialogChrome(panelRef);
|
||||
useDialogChrome(panelRef, rootRef);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Only while nothing is above us: an error dialog opened from inside the wizard owns the Tab.
|
||||
if (e.key !== "Tab" || !layer.isTopmost()) return;
|
||||
const panel = panelRef.current;
|
||||
if (panel) trapTab(e, panel);
|
||||
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
|
||||
// neither of them (round 6, on the commit that extracted the helper so the two shells could
|
||||
// not disagree). Scope is the whole dialog, so the toast host beside the panel stays in the
|
||||
// Tab cycle (a sticky connection notice's ✕ was unreachable from the keyboard); `container`
|
||||
// is the tabIndex=-1 panel, because `panel.contains(panel)` is true, so focus sitting ON the
|
||||
// panel — where a click on its dead space puts it — matched no branch at all and a Shift+Tab
|
||||
// walked out into the app behind the scrim.
|
||||
if (panel) trapTab(e, rootRef.current ?? panel, panel);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
@@ -145,7 +153,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<div className="fixed inset-0 z-overlay grid place-items-center p-4">
|
||||
<div ref={rootRef} className="fixed inset-0 z-overlay grid place-items-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]"
|
||||
onClick={close}
|
||||
|
||||
Reference in New Issue
Block a user