import { useEffect, useRef, useState, type RefObject } from "react"; // The focus half of the dialog contract, in ONE place. `ui/Modal` owns it for every dialog that goes // through the shell; the onboarding wizard renders its own shell (a bare `ui/Overlay`, because it is // not a titled dialog with a header row) and used to have NONE of this — no trap, no initial focus, // no restore — so a keyboard user tabbed straight out of the first-login wizard into the app behind // the scrim, where the focus ring is invisible (deep-review round 5). A second hand-written copy is // how two shells drift apart; this is the shared one. // Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically // focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a // collapsed disclosure) so the trap never lands focus on something the user can't see. const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; const focusablesIn = (root: HTMLElement): HTMLElement[] => [...root.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null); /** Keep Tab / Shift+Tab cycling inside `scope` instead of reaching the inert page behind the * backdrop. Call from a keydown handler that has already decided this dialog is the topmost layer — * a nested dialog must defer to its child. `container` is the scope's own tabIndex=-1 panel, if it * has one: focus can land there by clicking dialog dead space, and `Node.contains(self)` is true, so * without naming it a Shift+Tab from the panel would leak to the background. */ export function trapTab( e: KeyboardEvent, scope: HTMLElement, container?: HTMLElement | null, ): void { const items = focusablesIn(scope); const first = items[0]; const last = items[items.length - 1]; if (!first || !last) { e.preventDefault(); return; } const active = document.activeElement; if (!scope.contains(active) || (container != null && active === container)) { e.preventDefault(); (e.shiftKey ? last : first).focus(); } else if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } } /** 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): 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 // on . useState initializer runs exactly once, on first render. const [restoreTo] = useState(() => document.activeElement as HTMLElement | null); // What the dialog is focused ON, so a RE-mount can put the focus back where it was rather than on // the first control. React 18's StrictMode runs every effect mount→cleanup→mount in development: // the cleanup hands focus back to the trigger, and the second mount then found the focus outside // the dialog and moved it to the first focusable — taking it off the `autoFocus` field a beat // after the browser had put it there. Measured in Chrome: the command palette opened with the // focus on its ✕ in dev and on its search field in prod, from the same code (round 5). const focusedRef = useRef(null); // Whether the dialog still holds the focus. Read at cleanup rather than computed there: by the // time a passive cleanup runs on a real unmount the panel is already out of the document and the // focus has fallen to , so "does the panel contain activeElement" answers false for both a // close and a user who clicked away — and those two want opposite things. const heldRef = useRef(true); useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; // Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move // focus to where this dialog was focused before — or, the first time, to the first control, so // the dialog opens focused rather than stranded on the background. const scope = scopeRef.current; if (scope && !scope.contains(document.activeElement)) { const back = focusedRef.current; const target = (back?.isConnected && scope.contains(back) ? back : null) ?? focusablesIn(scope)[0] ?? scope; target.focus(); } const onFocusIn = () => { const inside = scopeRef.current?.contains(document.activeElement) ?? false; heldRef.current = inside; if (inside) focusedRef.current = document.activeElement as HTMLElement; }; onFocusIn(); document.addEventListener("focusin", onFocusIn); return () => { document.removeEventListener("focusin", onFocusIn); document.body.style.overflow = prev; // Restore focus to the trigger, if it's still in the document and focusable — and only if the // dialog still held the focus. Once the user has moved it elsewhere, taking it back is the // rudest thing a closing dialog can do. if (heldRef.current && restoreTo && restoreTo.isConnected) restoreTo.focus(); }; }, [restoreTo, scopeRef]); }