diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx
index c19f8d3e..bc6bb059 100644
--- a/frontend/src/components/Home.dom.test.tsx
+++ b/frontend/src/components/Home.dom.test.tsx
@@ -372,8 +372,8 @@ describe("Home", () => {
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,
// 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
- // `loading` into `disabled`, swallow clicks after actions the user never associated with a
+ // so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
+ // `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.
const rerollBtn = () =>
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
// 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.
- expect(rerollBtn().disabled).toBe(true);
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
await act(async () => {
@@ -409,7 +408,6 @@ describe("Home", () => {
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
- expect(rerollBtn().disabled).toBe(false);
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
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
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.
discoverPick.mockReturnValue(new Promise(() => {}));
@@ -442,7 +440,6 @@ describe("Home", () => {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
- expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
});
@@ -479,14 +476,14 @@ describe("Home", () => {
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
fireEvent.click(screen.getByText("home.hero.reroll"));
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"));
// 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 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).
- await waitFor(() => expect(rerollBtn().disabled).toBe(false));
+ await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull());
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull();
});
diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx
index e24c3c67..dee9b97c 100644
--- a/frontend/src/components/PlayerModal.dom.test.tsx
+++ b/frontend/src/components/PlayerModal.dom.test.tsx
@@ -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
, 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(
+ {}} />,
+ );
+ };
+
+ 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("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();
+ }
+ });
+});
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index 15ccd35e..2159391a 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -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(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({
{/* Title row — title (with hover description) on the left, Close on the right. */}
-
+
{/* Hover target is the text itself (inline), not the whole row. */}
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();
+ 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) => (
+
{i.body}
+ ))}
+ >
+ ),
+}));
+
+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(
+
+
+ {}}
+ collapsed={false}
+ onToggleCollapse={() => {}}
+ />
+
+ ,
+ );
+ 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()));
+ });
+});
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx
index e07391f0..ec8053cf 100644
--- a/frontend/src/components/PlaylistsRail.tsx
+++ b/frontend/src/components/PlaylistsRail.tsx
@@ -167,35 +167,20 @@ export default function PlaylistsRail({
},
});
- // KNOWN GAP: Enter and Escape unmount the focused and re-render the row as a