Files
siftlode/frontend/src/components/PlexPlayer.tabtrap.dom.test.tsx
T
peter 5249c41edc fix(player): a fullscreen button the browser cannot honour is not shown
Brave on iPadOS switches element fullscreen off, patches requestFullscreen with
a video-only stand-in and fakes fullscreenEnabled, so the Plex fullscreen button
and the YouTube docked bar's did nothing. Support is now read off
Element.prototype and the buttons are left out without it (owner). A stand-in
that returns a boolean no longer throws. A test's bare matchMedia stub no
longer outlives its describe.
2026-10-08 02:50:51 +02:00

208 lines
8.0 KiB
TypeScript

// @vitest-environment jsdom
//
// The Plex player paints an opaque, full-viewport layer over the whole app — and for a long
// time it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar,
// from its menus, or from the debug recorder's pill (which docks in THIS player's toast host) walked
// straight out into the page behind it, where the focus ring is invisible and the next Enter fires
// something the user cannot see. `ui/Modal`, `PlayerModal`, `OnboardingWizard` and `Welcome` all trap;
// an earlier comment that handed the step prompt's trap to "the dialog below" assumed this one did
// too. Its own file because `layerStack` is module state and these tests push into it.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, waitFor, act, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { pushLayer, removeLayer } from "../lib/layerStack";
beforeAll(() => {
// A browser that can fullscreen an element, as every desktop one can: the row's last control is
// the fullscreen button there (jsdom has no Fullscreen API, so the button would be left out).
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
for (const [name, value] of Object.entries({
play: () => Promise.resolve(),
pause: () => {},
load: () => {},
}))
Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value });
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
configurable: true,
get: () => ({ length: 0, end: () => 0 }),
});
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
configurable: true,
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
});
});
const behind: HTMLButtonElement[] = [];
afterEach(() => {
cleanup();
while (behind.length) behind.pop()!.remove();
vi.restoreAllMocks();
});
const detail = {
id: "1",
title: "Title 1",
type: "movie",
duration_seconds: 3000,
status: "new",
position_seconds: 0,
audio_streams: [],
subtitle_streams: [],
markers: [],
} as unknown as PlexItemDetail;
async function mount() {
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
vi.spyOn(api, "plexSession").mockResolvedValue({
mode: "direct",
url: "/media/1",
start_s: 0,
} as Awaited<ReturnType<typeof api.plexSession>>);
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="1" onClose={() => {}} />
</QueryClientProvider>,
);
await waitFor(() =>
expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"),
);
}
/** A control on the page the player is painted over — the thing focus used to escape onto. */
function pageBehind() {
const b = document.createElement("button");
b.textContent = "on the page behind";
document.body.insertBefore(b, document.body.firstChild);
behind.push(b);
return b;
}
const player = () => document.querySelector<HTMLElement>(".fixed.z-overlay")!;
/** jsdom has no layout, so the trap's visibility filter (`offsetParent`) would see nothing tabbable
* anywhere. Give every element one for the duration of a check — the house pattern (PlayerModal,
* OnboardingWizard, ui/dialogFocus all do this). */
function withLayout<T>(fn: () => T): T {
const spy = vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body);
try {
return fn();
} finally {
spy.mockRestore();
}
}
const press = (key: string, shiftKey = false) =>
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }),
);
});
// A menu's rows are named by their text: the surface is `visibility: hidden` until floating-ui has
// measured it (never, under jsdom), and a hidden element's accessible name is empty.
const menuItem = (label: string) =>
waitFor(() => {
const row = screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
expect(row).toBeDefined();
return row!;
});
describe("PlexPlayer · Tab stays inside the player", () => {
it("pulls the focus back in when it is on the page behind", async () => {
await mount();
const outside = pageBehind();
outside.focus();
expect(document.activeElement).toBe(outside);
withLayout(() => press("Tab"));
// Not left on a control under an opaque overlay: the trap takes it into the player.
expect(document.activeElement).not.toBe(outside);
expect(player().contains(document.activeElement)).toBe(true);
});
it("wraps from the last of its own controls back to the first", async () => {
await mount();
const items = [
...player().querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
),
];
expect(items.length).toBeGreaterThan(1);
const first = items[0]!;
const last = items[items.length - 1]!;
last.focus();
withLayout(() => press("Tab"));
expect(document.activeElement).toBe(first);
// ...and backwards, which is the direction that used to walk straight out behind the player.
first.focus();
withLayout(() => press("Tab", true));
expect(document.activeElement).toBe(last);
});
it("keeps trapping while one of its OWN menus is open", async () => {
await mount();
// The settings and track menus are `useDismiss` layers. That hook owns Escape and the
// outside-press and installs no Tab trap at all — but it used to register as a layer that
// DOES trap, so the player stood its own trap down for it and Tab walked out past the last
// control into the nav rail. Measured in a real browser: 30 Tabs, 9 escapes. The menus render
// inside the player, so the player's trap is the one that must stay on.
const buttons = () => [...player().querySelectorAll<HTMLElement>("button:not([disabled])")];
const before = buttons().length;
// The tuning panel opens from the ⋯ (C5): ⋯, then its Settings row.
const more = buttons().find((b) => b.getAttribute("aria-label") === "plex.player.more");
expect(more).toBeDefined();
act(() => more!.click());
// The ⋯ is a menu on the popover engine: it portals out of the player and traps for itself.
const settings = await menuItem("plex.player.settings");
act(() => settings.click());
await screen.findByRole("dialog", { name: "plex.player.settings" });
const items = buttons();
// The panel really opened — the trap now has to span its controls too.
expect(items.length).toBeGreaterThan(before + 3);
const last = items[items.length - 1]!;
last.focus();
withLayout(() => press("Tab"));
expect(player().contains(document.activeElement)).toBe(true);
expect(document.activeElement).toBe(items[0]);
});
it("leaves the Tab to a dialog opened above it, which traps for itself", async () => {
await mount();
const outside = pageBehind();
outside.focus();
const above = pushLayer(); // a Modal over the player — the download dialog, say
try {
withLayout(() => press("Tab"));
// The player does not reach past it: the dialog above owns the keyboard and its own trap.
expect(document.activeElement).toBe(outside);
} finally {
removeLayer(above);
}
});
});