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:
2026-09-13 01:04:11 +02:00
parent 92c305bab9
commit b4aea7fa5b
14 changed files with 480 additions and 86 deletions
+31 -9
View File
@@ -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]);
}