fix(keys): a list inside the topmost layer walks by keyboard

The list keys are the page's and an open layer silences them, so the
player's related videos could only be reached by Tab. A layer now names
its root element; with the focus on a row inside the topmost one, j/k,
the arrows and Enter walk that list, the walk stays inside the layer,
and the player's seek and volume arrows leave a focused row alone.
This commit is contained in:
2026-10-24 22:45:26 +02:00
parent 5205091116
commit 7d45c4f59f
8 changed files with 194 additions and 42 deletions
@@ -61,7 +61,11 @@ const base = {
getDuration: () => clock.duration,
getVideoData: () => null,
loadVideoById: (o: { videoId: string; startSeconds: number }) => void loaded.push(o),
seekTo: (s: number) => void seeks.push(s),
setVolume: (v: number) => void volumes.push(v),
};
const seeks: number[] = [];
const volumes: number[] = [];
// Anything else the modal calls on the player (volume, rate, destroy…) is a no-op. Not `then`:
// a thenable would be swallowed by the first `await` that touched it.
const fakePlayer = new Proxy(base, {
@@ -100,6 +104,8 @@ afterEach(() => {
vi.restoreAllMocks();
started.length = 0;
loaded.length = 0;
seeks.length = 0;
volumes.length = 0;
});
const lastStart = () => started[started.length - 1];
@@ -814,6 +820,70 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
});
// The list keys are the page's, silenced while a layer is up — but this list is IN the layer.
const press = (key: string) =>
act(() => void fireEvent.keyDown(document.activeElement ?? document.body, { key }));
const rowOf = (el: Element | null) => el?.closest("[data-nav-index]");
it("wide: the keyboard walks the related list, and its arrows are not the video's", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2")],
has_more: false,
next_cursor: null,
});
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
const first = titleOf(await screen.findAllByRole("link", { name: "Video rel1" }));
const second = titleOf(screen.getAllByRole("link", { name: "Video rel2" }));
act(() => first.focus());
// (jsdom lays nothing out, so the rows read as one row: ←→ step them, ↑↓ have no row to go to.)
press("ArrowRight");
expect(document.activeElement).toBe(rowOf(second));
press("ArrowLeft");
expect(document.activeElement).toBe(rowOf(first));
press("j");
expect(document.activeElement).toBe(rowOf(second));
press("k");
press("j");
// None of the arrows reached the video: no seek, no volume step.
press("ArrowUp");
press("ArrowDown");
expect(seeks).toEqual([]);
expect(volumes).toEqual([]);
// Enter on the row plays it, like its title's click.
press("Enter");
expect(loaded).toEqual([{ videoId: "rel2", startSeconds: 0 }]);
});
it("stacked: the keyboard walks the list in the drawer too", async () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2")],
has_more: false,
next_cursor: null,
});
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
const drawer = await screen.findByRole("dialog", { name: "player.related.title" });
const first = titleOf(await within(drawer).findAllByRole("link", { name: "Video rel1" }));
act(() => first.focus());
press("j");
expect(document.activeElement).toBe(
rowOf(titleOf(within(drawer).getAllByRole("link", { name: "Video rel2" }))),
);
});
it("stacked: a button opens the related videos in a drawer, and playing one closes it", async () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));