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
+5 -8
View File
@@ -372,8 +372,8 @@ describe("Home", () => {
it("an unrelated mutation does not put the reroll button into a spinner", async () => { it("an unrelated mutation does not put the reroll button into a spinner", async () => {
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix, // The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND — // every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
// so binding the button's `loading` to raw `isFetching` made it spin and, since `ui/Button` turns // so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
// `loading` into `disabled`, swallow clicks after actions the user never associated with a // `ui/Button` swallows its clicks, ignore presses after actions the user never associated with a
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own. // reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
const rerollBtn = () => const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement; screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
@@ -391,7 +391,6 @@ describe("Home", () => {
// The positive control, and it is the half most easily broken while fixing the other: this is the // The positive control, and it is the half most easily broken while fixing the other: this is the
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting // FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it. // on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
expect(rerollBtn().disabled).toBe(true);
expect(rerollBtn().getAttribute("aria-busy")).toBe("true"); expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
await act(async () => { await act(async () => {
@@ -409,7 +408,6 @@ describe("Home", () => {
}); });
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2)); await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull(); expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
}); });
@@ -434,7 +432,7 @@ describe("Home", () => {
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so // `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red. // "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
failPick(new Error("boom")); failPick(new Error("boom"));
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); // the failure releases the button await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull()); // the failure releases the button
// Now an unrelated action refetches that errored key in the background. // Now an unrelated action refetches that errored key in the background.
discoverPick.mockReturnValue(new Promise(() => {})); discoverPick.mockReturnValue(new Promise(() => {}));
@@ -442,7 +440,6 @@ describe("Home", () => {
void qc.invalidateQueries({ queryKey: qk.discover() }); void qc.invalidateQueries({ queryKey: qk.discover() });
}); });
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2)); await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull(); expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
}); });
@@ -479,14 +476,14 @@ describe("Home", () => {
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement; screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
fireEvent.click(screen.getByText("home.hero.reroll")); fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2)); await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(true); // the press is outstanding expect(rerollBtn().getAttribute("aria-busy")).toBe("true"); // the press is outstanding
failPick(new Error("boom")); failPick(new Error("boom"));
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies // Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while // the observer through its own `setTimeout(0)`, and the empty state is already on screen while
// the press is in flight — so asserting it straight away would pass with or without the fix. // the press is in flight — so asserting it straight away would pass with or without the fix.
// The button releasing is that signal (`isFetched` flips once the key settles, error included). // The button releasing is that signal (`isFetched` flips once the key settles, error included).
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull());
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy(); expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull(); expect(screen.queryByText("Hero v1")).toBeNull();
}); });
@@ -290,3 +290,63 @@ describe("PlayerModal — toasts raised while it is open", () => {
} }
}); });
}); });
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
// Until E11 this was the one `aria-modal` shell with no accessible name, no Tab trap and no focus
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
// Escape left a keyboard user on <body>, their place in a long feed lost (deep-review round 7).
describe("PlayerModal — the dialog contract", () => {
const open = (onClose = () => {}) => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
return renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
};
it("is named by the video's title", () => {
open();
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
});
it("opens focused on its card, not on a control, so its shortcuts keep working", () => {
open();
const active = document.activeElement as HTMLElement;
expect(active.tagName).not.toBe("BUTTON");
expect(screen.getByTestId("player-modal").contains(active)).toBe(true);
});
it("gives the focus back to what opened it when it closes", () => {
const opener = document.createElement("button");
document.body.appendChild(opener);
opener.focus();
try {
const view = open();
expect(document.activeElement).not.toBe(opener);
view.unmount();
expect(document.activeElement).toBe(opener);
} finally {
opener.remove();
}
});
it("keeps Tab inside the player", () => {
open();
const dialog = screen.getByTestId("player-modal");
const tabbables = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]")];
const last = tabbables[tabbables.length - 1]!;
last.focus();
// jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every
// element an offsetParent for the duration of this check.
const spy = vi
.spyOn(HTMLElement.prototype, "offsetParent", "get")
.mockReturnValue(document.body);
try {
fireEvent.keyDown(window, { key: "Tab" });
expect(dialog.contains(document.activeElement)).toBe(true);
expect(document.activeElement).not.toBe(last);
} finally {
spy.mockRestore();
}
});
});
+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 { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
@@ -33,6 +33,7 @@ import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack"; import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api"; import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube"; import type { YTPlayer } from "../lib/youtube";
@@ -166,6 +167,12 @@ export default function PlayerModal({
// modal adjusts volume (not just the small player area). // modal adjusts volume (not just the small player area).
const dialogRef = useRef<HTMLDivElement | null>(null); const dialogRef = useRef<HTMLDivElement | null>(null);
const focusModal = () => cardRef.current?.focus(); 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 togglePlay = () => {
const p = playerRef.current; const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return; if (!p || typeof p.getPlayerState !== "function") return;
@@ -422,6 +429,13 @@ export default function PlayerModal({
nudgeSeek(secs); nudgeSeek(secs);
}; };
const onKey = (e: KeyboardEvent) => { 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", { dispatch(e, "ytPlayer", {
close: (ev) => { close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an // 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); window.addEventListener("keydown", onKey);
focusModal(); focusModal();
const prevOverflow = document.body.style.overflow; // (The scroll lock is the shared, counted one in useDialogChrome.)
document.body.style.overflow = "hidden";
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.) // (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
}; };
}, [onClose]); }, [onClose]);
@@ -511,6 +523,7 @@ export default function PlayerModal({
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId}
> >
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a {/* 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 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"> <div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */} {/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3"> <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. */} {/* Hover target is the text itself (inline), not the whole row. */}
<span <span
ref={descPop.refs.setReference} ref={descPop.refs.setReference}
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
//
// The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render
// the row as a NEW button; without a refocus the keyboard user was dropped on <body> and lost their
// place (E10 review round 1, measured again in Chrome in E11 S2). Everything around the rail that is
// not the rename path is stubbed.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
});
vi.mock("./PlaylistsProvider", () => ({
usePlaylists: () => ({ search: "", selectedId: 1 }),
usePlaylistsActions: () => ({ setSelectedId: vi.fn() }),
}));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
vi.mock("./comfortPin", () => ({
useComfortPin: () => ({ item: () => [] }),
pinPlaylist: (id: number) => id,
}));
const rename = vi.fn(async () => {});
vi.mock("./usePlaylistActions", () => ({
usePlaylistActions: () => ({ rename, remove: vi.fn() }),
}));
// The SidePanel/PanelGroups chrome is not what is under test: render the rail's bodies directly.
vi.mock("./FilterRail", () => ({
default: ({ items }: { items: { id: string; body: ReactNode }[] }) => (
<>
{items.map((i) => (
<div key={i.id}>{i.body}</div>
))}
</>
),
}));
import PlaylistsRail from "./PlaylistsRail";
import { api, type Playlist } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
Element.prototype.scrollIntoView ??= () => {};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
rename.mockClear();
});
const alpha = {
id: 1,
name: "alpha",
kind: "custom",
source: "local",
item_count: 3,
total_duration_seconds: 0,
cover_thumbnail: null,
dirty: false,
} as unknown as Playlist;
async function openRename() {
vi.spyOn(api, "playlists").mockResolvedValue([alpha]);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<PlaylistsRail
layout={{} as never}
setLayout={() => {}}
collapsed={false}
onToggleCollapse={() => {}}
/>
</FloatingTree>
</QueryClientProvider>,
);
const row = (await screen.findByText("alpha")).closest("button")!;
act(() => row.focus());
fireEvent.keyDown(row, { key: "F10", shiftKey: true });
fireEvent.click(await screen.findByText("playlists.rename"));
// The menu runs its action a task after it closes (see ui/ActionMenu).
const field = await screen.findByRole("textbox", { name: "playlists.rename" });
await vi.waitFor(() => expect(document.activeElement).toBe(field));
return field as HTMLInputElement;
}
const rowButton = () => screen.getByText("alpha").closest("button");
describe("PlaylistsRail · rename from the keyboard", () => {
it("Escape cancels and puts the focus back on the row", async () => {
const field = await openRename();
fireEvent.change(field, { target: { value: "alpha2" } });
fireEvent.keyDown(field, { key: "Escape" });
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
expect(rename).not.toHaveBeenCalled();
});
it("Enter commits and puts the focus back on the row", async () => {
const field = await openRename();
fireEvent.change(field, { target: { value: "beta" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(rename).toHaveBeenCalledWith(1, "beta");
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
});
});
+28 -30
View File
@@ -167,35 +167,20 @@ export default function PlaylistsRail({
}, },
}); });
// KNOWN GAP: Enter and Escape unmount the focused <input> and re-render the row as a <button> that // Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10 // this a keyboard user was dropped on <body> and lost their place in the rail (E10 review round 1).
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a // The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome (E11
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves // S2), a focus issued after the commit sticks and nothing takes it back — the round-1 attempt that
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable // "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were // blur-commit means the user put the focus somewhere else on purpose.
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks const refocusRowRef = useRef<number | null>(null);
// this up: instrument the effect in a real browser first, do not reason about the commit order.
const renameInputRef = useRef<HTMLInputElement | null>(null); const renameInputRef = useRef<HTMLInputElement | null>(null);
// UNRESOLVED — do not "simplify" this back to `autoFocus` without reading the whole story. // Opening this field from the context menu used to race the menu's own focus return (floating-ui
// // hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and // mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and // return, so the field simply mounts and is focused here; measured in Chrome (E11 S2), the field is
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its // focused and survives.
// layout-effect cleanup. If that microtask wins, the field is blurred straight back out and its
// own onBlur commits and closes it — "the rename field flashes and vanishes" (E10 review round 1).
//
// Three answers have been tried and none is VERIFIED. Round 1 turned floating-ui's returnFocus off
// platform-wide and broke two other surfaces (a non-focusable <tr> trigger; a scroll-dismiss that
// scrolled the list back). Round 2 replaced it with this effect, on the reasoning that a passive
// effect is scheduled after the microtask. Round 3 disputed that (React flushes passive effects
// synchronously at the end of a SyncLane commit, which would put the microtask LAST) — and a jsdom
// harness built to settle it reproduced no failure for ANY of autoFocus / this effect / a
// microtask, i.e. jsdom does not exercise the race, because focus never enters the menu there.
//
// So: this is the shape that is deliberate rather than accidental, and the question is open. It
// needs ONE real-browser check (open the rail's context menu, pick Rename, see whether the field
// survives) — not a fourth guess. Logged in the E10 plan.
useEffect(() => { useEffect(() => {
if (renamingId !== null) renameInputRef.current?.focus(); if (renamingId !== null) renameInputRef.current?.focus();
}, [renamingId]); }, [renamingId]);
@@ -338,9 +323,15 @@ export default function PlaylistsRail({
onChange={(e) => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename(pl)} onBlur={() => void commitRename(pl)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(pl); if (e.key === "Enter") {
refocusRowRef.current = pl.id;
void commitRename(pl);
}
// Unmounting the input fires no blur in React, so Escape really does cancel. // Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") setRenamingId(null); if (e.key === "Escape") {
refocusRowRef.current = pl.id;
setRenamingId(null);
}
}} }}
aria-label={t("playlists.rename")} aria-label={t("playlists.rename")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40" className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
@@ -349,7 +340,14 @@ export default function PlaylistsRail({
) : ( ) : (
<button <button
key={pl.id} key={pl.id}
ref={pl.id === selectedId ? selectedRef : null} ref={(el) => {
if (pl.id === selectedId) selectedRef.current = el;
// Back from the rename field by Enter/Escape — see refocusRowRef.
if (el && refocusRowRef.current === pl.id) {
refocusRowRef.current = null;
el.focus();
}
}}
onClick={() => setSelectedId(pl.id)} onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)} {...railMenu.triggerProps(pl)}
className={`${ROW_BASE} text-left ${ className={`${ROW_BASE} text-left ${
@@ -54,13 +54,18 @@ describe("ActionMenu", () => {
]); ]);
}); });
it("selecting an item fires its onSelect and closes the menu", () => { // Close first, act on a later task: floating-ui hands the focus back to the trigger from a microtask
// as the menu unmounts, and an action run in that same batch (a confirm dialog) had its focus taken
// back to the trigger behind the dialog's scrim — measured in Chrome, E11 S2. jsdom does not run
// that focus race, so the ORDER is what is pinned: the menu is gone before the action runs.
it("selecting an item closes the menu first, then fires its onSelect once", async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
renderMenu([{ id: "sync", label: "Sync", onSelect }]); renderMenu([{ id: "sync", label: "Sync", onSelect }]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
fireEvent.click(screen.getByText("Sync")); fireEvent.click(screen.getByText("Sync"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull(); expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
expect(onSelect).not.toHaveBeenCalled();
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
}); });
it("a disabled item is inert — rendered disabled and never fires onSelect", () => { it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
+8 -1
View File
@@ -83,8 +83,15 @@ export function MenuItemList({
label={it.label} label={it.label}
title={it.title} title={it.title}
onSelect={() => { onSelect={() => {
it.onSelect(); // Close FIRST, act once the menu has handed the focus back. floating-ui returns the
// focus to the trigger from a microtask queued as the menu unmounts, so an action run
// in the same batch had its focus taken back by it: measured in Chrome (E11 S2), a
// confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
// 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task
// runs after that microtask — and a dialog opened there captures the trigger, not a
// dying menu item, as the element to restore the focus to.
onClose(); onClose();
setTimeout(it.onSelect, 0);
}} }}
className={`${ROW}${it.danger ? " text-danger" : ""}`} className={`${ROW}${it.danger ? " text-danger" : ""}`}
> >
+35 -7
View File
@@ -39,7 +39,7 @@ describe("Button primitive (S5)", () => {
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull(); expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
}); });
it("loading disables the button, marks aria-busy, and blocks clicks", () => { it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => {
const onClick = vi.fn(); const onClick = vi.fn();
render( render(
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}> <Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
@@ -47,30 +47,58 @@ describe("Button primitive (S5)", () => {
</Button>, </Button>,
); );
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.getAttribute("aria-busy")).toBe("true"); expect(btn.getAttribute("aria-busy")).toBe("true");
expect(btn.getAttribute("aria-disabled")).toBe("true");
// the leading icon is swapped for a spinner // the leading icon is swapped for a spinner
expect(btn.querySelector("[data-testid=ic]")).toBeNull(); expect(btn.querySelector("[data-testid=ic]")).toBeNull();
fireEvent.click(btn); fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled(); expect(onClick).not.toHaveBeenCalled();
}); });
it("loading disables even when an explicit disabled={false} is passed", () => { // E11 S2, measured in Chrome: a focused button that becomes natively disabled drops the focus to
// <body> and never gets it back, so every "Something else" press threw a keyboard user out of Home.
// jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off.
it("loading keeps the button natively enabled, so it keeps the focus", () => {
render(<Button loading>Reroll</Button>);
const btn = screen.getByRole("button", { name: "Reroll" }) as HTMLButtonElement;
expect(btn.disabled).toBe(false);
});
it("loading blocks clicks even when an explicit disabled={false} is passed", () => {
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a // Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed // call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
// clickable mid-mutation and double-submitted. The contract is: loading always disables. // clickable mid-mutation and double-submitted. The contract is: loading always blocks.
const onClick = vi.fn(); const onClick = vi.fn();
render( render(
<Button disabled={false} loading onClick={onClick}> <Button disabled={false} loading onClick={onClick}>
Create Create
</Button>, </Button>,
); );
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement; fireEvent.click(screen.getByRole("button", { name: "Create" }));
expect(btn.disabled).toBe(true);
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled(); expect(onClick).not.toHaveBeenCalled();
}); });
it("a busy submit button cancels the form's submission", () => {
// Enter in a field submits through a click on the default button; the guard cancels that click.
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
render(
<form onSubmit={onSubmit}>
<Button type="submit" loading>
Send
</Button>
</form>,
);
fireEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onSubmit).not.toHaveBeenCalled();
});
it("a non-busy button still calls onClick", () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Go</Button>);
fireEvent.click(screen.getByRole("button", { name: "Go" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("a link gets no button padding, and an explicit size still wins", () => { it("a link gets no button padding, and an explicit size still wins", () => {
// The size default is variant-dependent (link → "none"). Locking it because the failure is // The size default is variant-dependent (link → "none"). Locking it because the failure is
// silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors. // silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
+37 -19
View File
@@ -44,28 +44,32 @@ const VARIANTS: Record<ButtonVariant, string> = {
// and the ~100 hand-rolled ones had drifted into two different looks. That split IS the // and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
// inconsistency this epic exists to remove; the primitive follows the majority, not the reverse. // inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
outline: outline:
"font-medium border border-border text-muted enabled:hover:text-accent enabled:hover:border-accent", "font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-accent enabled:not-aria-disabled:hover:border-accent",
"outline-accent": "font-medium border border-accent/40 text-accent enabled:hover:bg-accent/10", "outline-accent":
"font-medium border border-accent/40 text-accent enabled:not-aria-disabled:hover:bg-accent/10",
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference // Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
// is whether the button should advertise the destruction before the pointer arrives. // is whether the button should advertise the destruction before the pointer arrives.
"outline-danger": "outline-danger":
"font-medium border border-border text-muted enabled:hover:text-danger enabled:hover:border-danger/40", "font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-danger enabled:not-aria-disabled:hover:border-danger/40",
"outline-warn": "font-medium border border-warn/40 text-warn enabled:hover:bg-warn/10", "outline-warn":
ghost: "font-medium text-muted enabled:hover:text-fg enabled:hover:bg-card", "font-medium border border-warn/40 text-warn enabled:not-aria-disabled:hover:bg-warn/10",
glass: "font-medium glass-card glass-hover enabled:hover:text-accent", ghost:
"font-medium text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:bg-card",
glass: "font-medium glass-card glass-hover enabled:not-aria-disabled:hover:text-accent",
"danger-soft": "danger-soft":
"font-medium border border-danger/60 text-danger enabled:hover:bg-danger/10 enabled:hover:border-danger", "font-medium border border-danger/60 text-danger enabled:not-aria-disabled:hover:bg-danger/10 enabled:not-aria-disabled:hover:border-danger",
// No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would // No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
// be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge // be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
// the winner is decided by the generated stylesheet order, not by the class list order). // the winner is decided by the generated stylesheet order, not by the class list order).
link: "font-semibold text-accent enabled:hover:underline", link: "font-semibold text-accent enabled:not-aria-disabled:hover:underline",
}; };
// Why every hover above is `enabled:hover:` — Tailwind's `:not(:disabled)`. A disabled button that // Why every hover above is `enabled:not-aria-disabled:hover:` — neither natively disabled nor busy. A
// still lights up on hover advertises a click that does nothing, and the ONE migrated call site that // disabled button that still lights up on hover advertises a click that does nothing, and the ONE
// had guarded its own hover (TagManager's Add) silently lost the guard when it moved onto this // migrated call site that had guarded its own hover (TagManager's Add) silently lost the guard when it
// primitive (E10 review round 1). The contract belongs here, not in ~160 call sites: BASE dims a // moved onto this primitive (E10 review round 1). `not-aria-disabled` is the `loading` half: a busy
// disabled button, and the variant must not paint over that dimming. // button stays natively enabled (see `loading` below) and must not light up either. The contract
// belongs here, not in ~160 call sites: BASE dims both, and the variant must not paint over it.
// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The // The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The
// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm` // rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm`
@@ -86,7 +90,7 @@ const SIZES: Record<ButtonSize, string> = {
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on // link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test. // one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE = const BASE =
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed"; "inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed";
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
@@ -98,7 +102,8 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
iconOnly?: boolean; iconOnly?: boolean;
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */ /** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
label?: string; label?: string;
/** Disable + swap the icon for a spinner + mark aria-busy. */ /** Busy: swap the icon for a spinner, mark aria-busy + aria-disabled, and swallow activation — but
* NOT native `disabled`, which would throw the focus out of the page (see the render below). */
loading?: boolean; loading?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
} }
@@ -131,6 +136,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
children, children,
disabled, disabled,
type, type,
onClick,
...rest ...rest
}, },
ref, ref,
@@ -149,11 +155,23 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
<button <button
ref={ref} ref={ref}
type={type ?? "button"} type={type ?? "button"}
// `loading` must always disable (see the prop's contract above), even when a call-site also // `loading` blocks activation WITHOUT native `disabled`. A focused button that turns disabled
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}` // loses the focus: Chrome moved it to <body> ~60 ms after the reroll click on Home and nothing
// would shadow `loading` and leave the button clickable mid-mutation (double-submit). // handed it back when the button re-enabled (measured, E11 S2) — so a keyboard user was dropped
disabled={disabled || loading} // out of the page on every press. `aria-disabled` keeps the button focusable and announced as
// unavailable; the click guard is what actually stops the double-submit, whatever `disabled`
// says, and its preventDefault also cancels a form's implicit submission (which is a click on
// the default button).
disabled={disabled}
aria-disabled={loading || undefined}
aria-busy={loading || undefined} aria-busy={loading || undefined}
onClick={(e) => {
if (loading) {
e.preventDefault();
return;
}
onClick?.(e);
}}
aria-label={label} aria-label={label}
title={label} title={label}
className={clsx( className={clsx(
@@ -108,13 +108,13 @@ describe("useContextMenu / ContextMenu", () => {
expect(menuitems()).toEqual([]); expect(menuitems()).toEqual([]);
}); });
it("selecting an item fires it and closes the menu", () => { it("selecting an item closes the menu, then fires it (see ActionMenu's order test)", async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
renderList(() => [{ id: "a", label: "Remove", onSelect }]); renderList(() => [{ id: "a", label: "Remove", onSelect }]);
fireEvent.contextMenu(row("alpha")); fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Remove")); fireEvent.click(item("Remove"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(menuitems()).toEqual([]); expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
}); });
it("Shift+F10 opens it from the keyboard", () => { it("Shift+F10 opens it from the keyboard", () => {
@@ -217,7 +217,7 @@ describe("useContextMenu / ContextMenu", () => {
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]); expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
}); });
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", () => { it("choosing inside a submenu closes the WHOLE stack, not just the submenu", async () => {
const chosen = vi.fn(); const chosen = vi.fn();
renderList(() => [ renderList(() => [
{ id: "play", label: "Play", onSelect: vi.fn() }, { id: "play", label: "Play", onSelect: vi.fn() },
@@ -226,8 +226,8 @@ describe("useContextMenu / ContextMenu", () => {
fireEvent.contextMenu(row("alpha")); fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Move to")); fireEvent.click(item("Move to"));
fireEvent.click(item("Homelab")); fireEvent.click(item("Homelab"));
expect(chosen).toHaveBeenCalledTimes(1);
expect(menuitems()).toEqual([]); expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(chosen).toHaveBeenCalledTimes(1));
}); });
}); });
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
//
// The parts of the shared dialog contract that are not already pinned through ui/Modal: the ONE
// counted page scroll lock, and `focusScope` (the player opens focused on its card, not a button).
import { useRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { useDialogChrome } from "./dialogFocus";
afterEach(cleanup);
function Dialog({ focusScope = false }: { focusScope?: boolean }) {
const ref = useRef<HTMLDivElement>(null);
useDialogChrome(ref, undefined, { focusScope });
return (
<div ref={ref} tabIndex={-1} data-testid="scope">
<button>first</button>
</div>
);
}
describe("useDialogChrome · the page scroll lock", () => {
// Each dialog used to save and restore `overflow` itself, so the one mounted SECOND but unmounted
// LAST put back the "hidden" it had saved — a page left locked with no dialog open.
it("unlocks only when the last dialog closes, in whatever order they close", () => {
document.body.style.overflow = "";
const player = render(<Dialog />);
const modal = render(<Dialog />);
expect(document.body.style.overflow).toBe("hidden");
player.unmount(); // the one underneath goes first
expect(document.body.style.overflow).toBe("hidden"); // the other is still open
modal.unmount();
expect(document.body.style.overflow).toBe("");
});
});
describe("useDialogChrome · initial focus", () => {
it("lands on the first control by default", () => {
// jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable.
const spy = vi
.spyOn(HTMLElement.prototype, "offsetParent", "get")
.mockReturnValue(document.body);
try {
const { getByText } = render(<Dialog />);
expect(document.activeElement).toBe(getByText("first"));
} finally {
spy.mockRestore();
}
});
it("lands on the scope itself with focusScope", () => {
// Same layout stub as above: without it jsdom sees no focusable child, so the scope would get
// the focus with or without the option and this would pin nothing.
const spy = vi
.spyOn(HTMLElement.prototype, "offsetParent", "get")
.mockReturnValue(document.body);
try {
const { getByTestId } = render(<Dialog focusScope />);
expect(document.activeElement).toBe(getByTestId("scope"));
} finally {
spy.mockRestore();
}
});
});
+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. /** 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 * `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 * 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( export function useDialogChrome(
scopeRef: RefObject<HTMLElement | null>, scopeRef: RefObject<HTMLElement | null>,
ownerRef?: RefObject<HTMLElement | null>, ownerRef?: RefObject<HTMLElement | null>,
{ focusScope = false }: { focusScope?: boolean } = {},
): void { ): void {
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the // 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 // 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). // this hook existed the restore was unconditional (deep-review round 6).
const heldRef = useRef(true); const heldRef = useRef(true);
useEffect(() => { useEffect(() => {
const prev = document.body.style.overflow; const unlockScroll = lockBodyScroll();
document.body.style.overflow = "hidden";
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move // 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 // focus to where this dialog was focused before — or, the first time, to the first control (or
// the dialog opens focused rather than stranded on the background. // the scope itself, with `focusScope`), so the dialog opens focused rather than stranded on the
// background.
const scope = scopeRef.current; const scope = scopeRef.current;
if (scope && !scope.contains(document.activeElement)) { if (scope && !scope.contains(document.activeElement)) {
const back = focusedRef.current; const back = focusedRef.current;
const target = const target =
(back?.isConnected && scope.contains(back) ? back : null) ?? (back?.isConnected && scope.contains(back) ? back : null) ??
focusablesIn(scope)[0] ?? (focusScope ? scope : (focusablesIn(scope)[0] ?? scope));
scope;
target.focus(); target.focus();
} }
const onFocusIn = () => { const onFocusIn = () => {
@@ -114,7 +136,7 @@ export function useDialogChrome(
document.addEventListener("focusin", onFocusIn); document.addEventListener("focusin", onFocusIn);
return () => { return () => {
document.removeEventListener("focusin", onFocusIn); 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 // 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 // dialog still held the focus. Once the user has moved it elsewhere, taking it back is the
// rudest thing a closing dialog can do. // rudest thing a closing dialog can do.
@@ -140,5 +162,5 @@ export function useDialogChrome(
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected) if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
restoreTo.focus(); restoreTo.focus();
}; };
}, [restoreTo, scopeRef, ownerRef]); }, [restoreTo, scopeRef, ownerRef, focusScope]);
} }
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
//
// The focus half of the side panels' peek-open model. What is pinned: a dialog opened from inside
// the panel (rendered at the app root, so outside it in both the DOM and the React tree) does not
// collapse the panel — collapsing unmounted the row the dialog hands the focus back to, stranding a
// keyboard user on <body> after Cancel (measured in Chrome, E11 S2).
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useHoverFocusWithin } from "./useHoverFocusWithin";
afterEach(cleanup);
function Panel() {
const peek = useHoverFocusWithin();
return (
<>
<div {...peek.focusHandlers} data-testid="panel">
<button>row</button>
{peek.active && <span>expanded</span>}
</div>
<div role="dialog" aria-modal="true">
<button>cancel</button>
</div>
<button>elsewhere</button>
</>
);
}
const moveFocus = (from: HTMLElement, to: HTMLElement) =>
act(() => {
fireEvent.blur(from, { relatedTarget: to });
to.focus();
});
describe("useHoverFocusWithin · focus", () => {
it("stays open while the focus is in a dialog opened over the page", () => {
render(<Panel />);
const row = screen.getByText("row");
act(() => row.focus());
expect(screen.queryByText("expanded")).not.toBeNull();
moveFocus(row, screen.getByText("cancel"));
expect(screen.queryByText("expanded")).not.toBeNull();
});
it("still closes when the focus leaves for the page itself", () => {
render(<Panel />);
const row = screen.getByText("row");
act(() => row.focus());
moveFocus(row, screen.getByText("elsewhere"));
expect(screen.queryByText("expanded")).toBeNull();
});
});
+10 -1
View File
@@ -58,7 +58,16 @@ export function useHoverFocusWithin() {
const focusHandlers = { const focusHandlers = {
onFocus: () => setFocused(true), onFocus: () => setFocused(true),
onBlur: (e: FocusEvent) => { onBlur: (e: FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false); const to = e.relatedTarget as Element | null;
if (e.currentTarget.contains(to)) return;
// A dialog opened FROM inside the panel (the rail row's "Delete?" confirm) renders at the app
// root, so neither the DOM nor the React tree sees it as inside — and collapsing the panel under
// it unmounted the very row the dialog hands the focus back to, leaving a keyboard user on
// <body> after Cancel (measured in Chrome, E11 S2). Hold while the focus is in a dialog: when it
// closes, the focus either comes back in here (onFocus) or lands nowhere, which the
// document-level net below clears.
if (to?.closest('[aria-modal="true"]')) return;
setFocused(false);
}, },
}; };