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:
@@ -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(() => {}));
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
anyLayerAbove,
|
||||
escapeHandled,
|
||||
} from "../../lib/layerStack";
|
||||
import { focusInNavList } from "../../lib/listNav";
|
||||
import { trapTab, useDialogChrome } from "../../components/ui/dialogFocus";
|
||||
import DownloadButton from "../downloads/DownloadButton";
|
||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||
@@ -709,10 +710,11 @@ export default function PlayerModal({
|
||||
// over the player by doing so.
|
||||
const overlayOrder = useOverlayOrder();
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
|
||||
// download dialog, or an anchored menu) takes Escape first and this player defers.
|
||||
// download dialog, or an anchored menu) takes Escape first and this player defers. Its root lets
|
||||
// the keyboard walk a list inside it (the related videos).
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
const id = pushLayer({ root: () => dialogRef.current });
|
||||
layerIdRef.current = id;
|
||||
return () => {
|
||||
removeLayer(id);
|
||||
@@ -740,7 +742,9 @@ export default function PlayerModal({
|
||||
// there: deferring would scroll something invisible and leave the viewer with no volume control at
|
||||
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
|
||||
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||
// On a row of a list in the player (the related videos) the arrows walk the list instead.
|
||||
const volume = (e: KeyboardEvent, up: boolean) => {
|
||||
if (focusInNavList()) return;
|
||||
const covered = isFullscreenRef.current || maximized;
|
||||
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
|
||||
return;
|
||||
@@ -748,6 +752,7 @@ export default function PlayerModal({
|
||||
nudgeVolume(up ? 5 : -5);
|
||||
};
|
||||
const seek = (e: KeyboardEvent, secs: number) => {
|
||||
if (focusInNavList()) return;
|
||||
e.preventDefault();
|
||||
nudgeSeek(secs);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user