fix(plex): the player's ⋯ and tracks lists are real menus, and every close returns the focus

They were hand-built divs: no menu roles, no arrow keys, and Escape left the
focus on <body>. Both run on the popover engine now (media-tone rows), and the
tuning panel is a labelled dialog that takes the focus and hands it back to ⋯.
This commit is contained in:
2026-09-27 14:22:37 +02:00
parent 9bedd96774
commit a975ab627a
8 changed files with 224 additions and 90 deletions
@@ -8,7 +8,7 @@
// the round-3 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 } from "@testing-library/react";
import { render, cleanup, waitFor, act, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -116,6 +116,17 @@ const press = (key: string, shiftKey = false) =>
);
});
// 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();
@@ -160,9 +171,10 @@ describe("PlexPlayer · Tab stays inside the player", () => {
const more = buttons().find((b) => b.getAttribute("aria-label") === "plex.player.more");
expect(more).toBeDefined();
act(() => more!.click());
const settings = buttons().find((b) => b.textContent === "plex.player.settings");
expect(settings).toBeDefined();
act(() => settings!.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);