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
+18 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider";
@@ -33,6 +33,7 @@ import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube";
@@ -166,6 +167,12 @@ export default function PlayerModal({
// modal adjusts volume (not just the small player area).
const dialogRef = useRef<HTMLDivElement | null>(null);
const focusModal = () => cardRef.current?.focus();
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
// hand-rolled shell — no accessible name, no Tab trap, no restore (deep-review round 7).
useDialogChrome(cardRef, dialogRef, { focusScope: true });
const titleId = useId();
const togglePlay = () => {
const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return;
@@ -422,6 +429,13 @@ export default function PlayerModal({
nudgeSeek(secs);
};
const onKey = (e: KeyboardEvent) => {
// The shared dialog Tab trap — only while nothing is layered above the player: a Modal opened
// over it (the download dialog) traps for itself.
if (e.key === "Tab") {
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
return;
}
dispatch(e, "ytPlayer", {
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
@@ -468,11 +482,9 @@ export default function PlayerModal({
};
window.addEventListener("keydown", onKey);
focusModal();
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// (The scroll lock is the shared, counted one in useDialogChrome.)
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
};
}, [onClose]);
@@ -511,6 +523,7 @@ export default function PlayerModal({
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
playlist). Each strip sits just outside the card, spans its full height, and fades out at
@@ -735,7 +748,7 @@ export default function PlayerModal({
<div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={descPop.refs.setReference}