fix(ui): dialogs, menus and busy buttons keep the keyboard focus where it belongs
Measured in Chrome before each change: - A dialog opened from a menu lost its focus: the menu row ran its action and closed in one batch, and floating-ui's focus return (a microtask) moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after it opened, where Enter activated the row. MenuItemList now closes first and runs the action a task later; the dialog also captures the row, not a dying menu item, as its restore target. - A side panel opened as a flyout collapsed the moment a dialog opened from inside it, unmounting the row the dialog hands the focus back to. useHoverFocusWithin holds while the focus is inside an aria-modal dialog. - ui/Button's loading state set native disabled; Chrome moved the focus of the pressed button to <body> and never gave it back. Loading is now aria-disabled with a click guard that also cancels implicit submission. - Escape/Enter in the playlist rail's rename field dropped the focus on <body>. The row refocuses from its ref callback when it re-attaches (a post-commit focus sticks; the earlier attempt ran before the button existed). - PlayerModal was the one aria-modal shell without the dialog contract: it is now named by its title, traps Tab, restores the focus to its opener and shares one counted body scroll lock with every dialog.
This commit is contained in:
@@ -46,13 +46,36 @@ export function trapTab(
|
||||
}
|
||||
}
|
||||
|
||||
// ONE body scroll lock for every open dialog, counted. Each dialog used to save and restore
|
||||
// `overflow` on its own, so two unmounting out of order (the player's cleanup running before a Modal
|
||||
// opened above it) put back a stale "hidden". Invisible today — the app scrolls inside <main>, not
|
||||
// the document (measured, E11 S2) — but a count cannot be restored out of order, and PlayerModal's
|
||||
// second copy of the lock is gone.
|
||||
let scrollLocks = 0;
|
||||
let unlockedOverflow = "";
|
||||
function lockBodyScroll(): () => void {
|
||||
if (scrollLocks++ === 0) {
|
||||
unlockedOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (--scrollLocks === 0) document.body.style.overflow = unlockedOverflow;
|
||||
};
|
||||
}
|
||||
|
||||
/** 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; `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`). */
|
||||
* disagree about what counts as "inside" (see `heldRef`). `focusScope` opens the dialog focused on
|
||||
* the scope element itself rather than its first control: the player keeps the focus on its card, so
|
||||
* Space and the arrows reach its shortcuts instead of activating a button. */
|
||||
export function useDialogChrome(
|
||||
scopeRef: RefObject<HTMLElement | null>,
|
||||
ownerRef?: RefObject<HTMLElement | null>,
|
||||
{ focusScope = false }: { focusScope?: boolean } = {},
|
||||
): 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
|
||||
@@ -79,18 +102,17 @@ export function useDialogChrome(
|
||||
// this hook existed the restore was unconditional (deep-review round 6).
|
||||
const heldRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const unlockScroll = lockBodyScroll();
|
||||
// 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.
|
||||
// focus to where this dialog was focused before — or, the first time, to the first control (or
|
||||
// the scope itself, with `focusScope`), 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;
|
||||
(focusScope ? scope : (focusablesIn(scope)[0] ?? scope));
|
||||
target.focus();
|
||||
}
|
||||
const onFocusIn = () => {
|
||||
@@ -114,7 +136,7 @@ export function useDialogChrome(
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", onFocusIn);
|
||||
document.body.style.overflow = prev;
|
||||
unlockScroll();
|
||||
// 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.
|
||||
@@ -140,5 +162,5 @@ export function useDialogChrome(
|
||||
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
|
||||
restoreTo.focus();
|
||||
};
|
||||
}, [restoreTo, scopeRef, ownerRef]);
|
||||
}, [restoreTo, scopeRef, ownerRef, focusScope]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user