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:
@@ -183,6 +183,17 @@ function pressPicture(pointerType: "touch" | "mouse", x = 450) {
|
|||||||
fireEvent.click(v, { clientX: x, clientY: 250 });
|
fireEvent.click(v, { clientX: x, clientY: 250 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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("a tap on the picture (C3)", () => {
|
describe("a tap on the picture (C3)", () => {
|
||||||
it("a mouse click plays or pauses; a finger's tap only shows or hides the controls", async () => {
|
it("a mouse click plays or pauses; a finger's tap only shows or hides the controls", async () => {
|
||||||
mount({ details: { A: item("A") } });
|
mount({ details: { A: item("A") } });
|
||||||
@@ -277,13 +288,58 @@ describe("the C5 row", () => {
|
|||||||
"plex.player.help",
|
"plex.player.help",
|
||||||
"plex.player.download",
|
"plex.player.download",
|
||||||
];
|
];
|
||||||
for (const r of rows) expect(screen.getByRole("button", { name: r }), r).toBeTruthy();
|
const menu = await screen.findByRole("menu", { hidden: true });
|
||||||
|
expect(
|
||||||
|
[...menu.querySelectorAll('[role="menuitem"]')].map(
|
||||||
|
(b) => b.getAttribute("aria-label") ?? b.textContent,
|
||||||
|
),
|
||||||
|
).toEqual(rows);
|
||||||
// A row closes the menu and opens its tool: Settings → the tuning panel.
|
// A row closes the menu and opens its tool: Settings → the tuning panel.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "plex.player.settings" }));
|
fireEvent.click(await menuItem("plex.player.settings"));
|
||||||
expect(screen.queryByRole("button", { name: "plex.player.stats.title" })).toBeNull();
|
await screen.findByRole("dialog", { name: "plex.player.settings" });
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.queryAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === "plex.player.stats.title") ?? null,
|
||||||
|
).toBeNull();
|
||||||
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
|
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E28 review D3: the ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape
|
||||||
|
// closed it with the focus left on <body>. It is a menu on the popover engine now.
|
||||||
|
it("walks the ⋯ with the arrows, and Escape gives the focus back to the ⋯", async () => {
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
const more = screen.getByLabelText("plex.player.more");
|
||||||
|
more.focus();
|
||||||
|
fireEvent.click(more);
|
||||||
|
const menu = await screen.findByRole("menu", { hidden: true });
|
||||||
|
const rows = [...menu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(rows[0]));
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
|
||||||
|
expect(document.activeElement).toBe(rows[1]);
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: "End" });
|
||||||
|
expect(document.activeElement).toBe(rows[rows.length - 1]);
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||||
|
await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(more));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the focus back to the ⋯ when the tuning panel closes from inside", async () => {
|
||||||
|
mount({ details: { A: item("A") } });
|
||||||
|
await playingAt("/media/A", 100);
|
||||||
|
const more = screen.getByLabelText("plex.player.more");
|
||||||
|
fireEvent.click(more);
|
||||||
|
fireEvent.click(await menuItem("plex.player.settings"));
|
||||||
|
const panel = await screen.findByRole("dialog", { name: "plex.player.settings" });
|
||||||
|
// Into the panel on open…
|
||||||
|
await waitFor(() => expect(panel.contains(document.activeElement)).toBe(true));
|
||||||
|
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
// …and back on the ⋯ when it goes, not on <body>.
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(more));
|
||||||
|
});
|
||||||
|
|
||||||
it("Next shows only when there is a next item", async () => {
|
it("Next shows only when there is a next item", async () => {
|
||||||
mount({ details: { A: item("A") } });
|
mount({ details: { A: item("A") } });
|
||||||
await playingAt("/media/A", 100);
|
await playingAt("/media/A", 100);
|
||||||
|
|||||||
@@ -261,9 +261,9 @@ describe("the subtitle poll and notice", () => {
|
|||||||
await screen.findByText("Title A");
|
await screen.findByText("Title A");
|
||||||
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
|
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
|
||||||
const off = await screen.findByText("plex.player.subOff");
|
const off = await screen.findByText("plex.player.subOff");
|
||||||
expect(off.closest("button")?.getAttribute("aria-pressed")).toBe("false");
|
expect(off.closest('[role="menuitemradio"]')?.getAttribute("aria-checked")).toBe("false");
|
||||||
const pressed = document.querySelectorAll('[aria-pressed="true"]');
|
const checked = document.querySelectorAll('[role="menuitemradio"][aria-checked="true"]');
|
||||||
expect([...pressed].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
|
expect([...checked].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// the round-3 comment that handed the step prompt's trap to "the dialog below" assumed this one did
|
// 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.
|
// 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 { 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";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
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", () => {
|
describe("PlexPlayer · Tab stays inside the player", () => {
|
||||||
it("pulls the focus back in when it is on the page behind", async () => {
|
it("pulls the focus back in when it is on the page behind", async () => {
|
||||||
await mount();
|
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");
|
const more = buttons().find((b) => b.getAttribute("aria-label") === "plex.player.more");
|
||||||
expect(more).toBeDefined();
|
expect(more).toBeDefined();
|
||||||
act(() => more!.click());
|
act(() => more!.click());
|
||||||
const settings = buttons().find((b) => b.textContent === "plex.player.settings");
|
// The ⋯ is a menu on the popover engine: it portals out of the player and traps for itself.
|
||||||
expect(settings).toBeDefined();
|
const settings = await menuItem("plex.player.settings");
|
||||||
act(() => settings!.click());
|
act(() => settings.click());
|
||||||
|
await screen.findByRole("dialog", { name: "plex.player.settings" });
|
||||||
const items = buttons();
|
const items = buttons();
|
||||||
// The panel really opened — the trap now has to span its controls too.
|
// The panel really opened — the trap now has to span its controls too.
|
||||||
expect(items.length).toBeGreaterThan(before + 3);
|
expect(items.length).toBeGreaterThan(before + 3);
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ import {
|
|||||||
} from "../lib/layerStack";
|
} from "../lib/layerStack";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
|
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { Slider } from "./ui/NativeControls";
|
import { Slider } from "./ui/NativeControls";
|
||||||
import { formatUnit } from "../lib/format";
|
import { formatUnit } from "../lib/format";
|
||||||
|
|
||||||
@@ -104,6 +105,10 @@ const RECLAIM_WINDOW_MS = 120_000;
|
|||||||
const BACK_TO_PREV_S = 3;
|
const BACK_TO_PREV_S = 3;
|
||||||
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
|
// C5: a touch double-tap on the picture's left or right third skips this far; each further tap
|
||||||
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
|
// inside the window skips again (the keyboard keeps ← / → at the viewer's own step).
|
||||||
|
// The player's menus sit on the always-dark media chrome, under white text, in every theme.
|
||||||
|
const PLAYER_MENU =
|
||||||
|
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
|
||||||
|
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
|
||||||
const TAP_SKIP_S = 10;
|
const TAP_SKIP_S = 10;
|
||||||
const DOUBLE_TAP_MS = 300;
|
const DOUBLE_TAP_MS = 300;
|
||||||
|
|
||||||
@@ -236,7 +241,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings
|
const [menuOpen, setMenuOpen] = useState(false); // the tuning panel (tabbed), from ⋯ → Settings
|
||||||
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
|
// The fixed ⋯ (C5): the rare tools — settings, media info, stats, shortcuts, the original file.
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const moreRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
|
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
|
||||||
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
|
||||||
"sync",
|
"sync",
|
||||||
@@ -244,9 +248,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
const audioRef = useRef<number | null>(null);
|
const audioRef = useRef<number | null>(null);
|
||||||
const menuOpenRef = useRef(false);
|
const menuOpenRef = useRef(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const tracksRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
|
menuOpenRef.current = menuOpen || tracksOpen || moreOpen; // keep the control bar up while one is open
|
||||||
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
|
||||||
const {
|
const {
|
||||||
@@ -265,21 +267,40 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
onPointerEnter: () => holdOnHover(true),
|
onPointerEnter: () => holdOnHover(true),
|
||||||
onPointerLeave: () => holdOnHover(false),
|
onPointerLeave: () => holdOnHover(false),
|
||||||
};
|
};
|
||||||
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
// The tuning panel stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
||||||
|
// It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is
|
||||||
|
// what closes it (below).
|
||||||
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
|
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
|
||||||
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
|
// The ⋯ and the tracks list are real menus on the popover engine (E28 review D3): roving arrows,
|
||||||
useDismiss(moreOpen, () => setMoreOpen(false), [moreRef, menuTriggerRef]);
|
// Home/End, Escape as a layer, and the focus back on the trigger however they close. They were
|
||||||
// The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
|
// hand-built <div>s — no roles, no arrows, and Escape left the focus on <body>. The engine portals
|
||||||
// Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
|
// them into the fullscreen element when there is one, so they show in fullscreen too.
|
||||||
// and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
|
const morePop = useAnchoredPopover({
|
||||||
const tracksFade = useScrollFade();
|
open: moreOpen,
|
||||||
const tracksFadeRef = tracksFade.ref;
|
// The ⋯ also CLOSES the tuning panel it opened, rather than stacking the menu over it.
|
||||||
const setTracksNode = useCallback(
|
onOpenChange: (open) => {
|
||||||
(node: HTMLElement | null) => {
|
if (open && menuOpen) setMenuOpen(false);
|
||||||
tracksRef.current = node as HTMLDivElement | null;
|
else setMoreOpen(open);
|
||||||
tracksFadeRef(node);
|
|
||||||
},
|
},
|
||||||
[tracksFadeRef],
|
placement: "top-end",
|
||||||
|
offset: 8,
|
||||||
|
});
|
||||||
|
const tracksPop = useAnchoredPopover({
|
||||||
|
open: tracksOpen,
|
||||||
|
onOpenChange: setTracksOpen,
|
||||||
|
placement: "top-end",
|
||||||
|
offset: 8,
|
||||||
|
fitHeight: true,
|
||||||
|
});
|
||||||
|
const tracksFade = useScrollFade();
|
||||||
|
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
|
||||||
|
const setMoreReference = morePop.refs.setReference;
|
||||||
|
const moreTriggerRef = useCallback(
|
||||||
|
(node: HTMLButtonElement | null) => {
|
||||||
|
menuTriggerRef.current = node;
|
||||||
|
setMoreReference(node);
|
||||||
|
},
|
||||||
|
[setMoreReference],
|
||||||
);
|
);
|
||||||
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
|
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
|
||||||
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
|
||||||
@@ -1593,74 +1614,89 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
|
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. Picking
|
||||||
|
a track keeps it open: the change is seen where it was made, and the other group is
|
||||||
|
one arrow away. */}
|
||||||
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||||||
<div className="relative">
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="player"
|
size="player"
|
||||||
round
|
round
|
||||||
ref={tracksTriggerRef}
|
ref={tracksPop.refs.setReference}
|
||||||
onClick={() => setTracksOpen((o) => !o)}
|
{...tracksPop.getReferenceProps()}
|
||||||
aria-expanded={tracksOpen}
|
aria-expanded={tracksOpen}
|
||||||
label={t("plex.player.tracks")}
|
label={t("plex.player.tracks")}
|
||||||
>
|
>
|
||||||
<Languages />
|
<Languages />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{tracksOpen && (
|
<PopoverSurface
|
||||||
|
popover={tracksPop}
|
||||||
|
ariaLabel={t("plex.player.tracks")}
|
||||||
|
className={`${PLAYER_MENU} w-56`}
|
||||||
|
>
|
||||||
|
{/* Portaled, but a React child of the player: a click or a wheel here must not
|
||||||
|
reach the stage's play-toggle and volume handlers. */}
|
||||||
<div
|
<div
|
||||||
ref={setTracksNode}
|
ref={tracksFade.ref}
|
||||||
style={tracksFade.style}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
|
style={{ ...tracksFade.style, maxHeight: "min(60dvh, var(--fit-max-h))" }}
|
||||||
|
className="overflow-auto no-scrollbar p-2"
|
||||||
>
|
>
|
||||||
{detail.audio_streams.length > 1 && (
|
{detail.audio_streams.length > 1 && (
|
||||||
<>
|
<div role="group" aria-label={t("plex.player.audio")}>
|
||||||
<PanelHead>{t("plex.player.audio")}</PanelHead>
|
<PanelHead>{t("plex.player.audio")}</PanelHead>
|
||||||
{detail.audio_streams.map((a) => (
|
{detail.audio_streams.map((a) => (
|
||||||
<Pressable
|
<PopoverItem
|
||||||
key={a.ord}
|
key={a.ord}
|
||||||
onClick={() => changeAudio(a.ord)}
|
tone="media"
|
||||||
aria-pressed={(audioOrd ?? 0) === a.ord}
|
role="menuitemradio"
|
||||||
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
|
checked={(audioOrd ?? 0) === a.ord}
|
||||||
|
label={a.label}
|
||||||
|
onSelect={() => changeAudio(a.ord)}
|
||||||
|
className={`${PLAYER_ROW} truncate ${
|
||||||
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{a.label}
|
{a.label}
|
||||||
</Pressable>
|
</PopoverItem>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
{textSubs.length > 0 && (
|
{textSubs.length > 0 && (
|
||||||
<>
|
<div role="group" aria-label={t("plex.player.subtitles")}>
|
||||||
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
||||||
<Pressable
|
<PopoverItem
|
||||||
onClick={() => changeSubtitle(null)}
|
tone="media"
|
||||||
aria-pressed={subOrd == null}
|
role="menuitemradio"
|
||||||
className={`block w-full rounded px-2 py-1 hover:bg-white/10 ${
|
checked={subOrd == null}
|
||||||
subOrd == null ? "text-accent" : "text-white"
|
label={t("plex.player.subOff")}
|
||||||
}`}
|
onSelect={() => changeSubtitle(null)}
|
||||||
|
className={`${PLAYER_ROW} ${subOrd == null ? "text-accent" : "text-white"}`}
|
||||||
>
|
>
|
||||||
{t("plex.player.subOff")}
|
{t("plex.player.subOff")}
|
||||||
</Pressable>
|
</PopoverItem>
|
||||||
{textSubs.map((s) => (
|
{textSubs.map((sub) => (
|
||||||
<Pressable
|
<PopoverItem
|
||||||
key={s.ord}
|
key={sub.ord}
|
||||||
onClick={() => changeSubtitle(s.ord)}
|
tone="media"
|
||||||
aria-pressed={subOrd === s.ord}
|
role="menuitemradio"
|
||||||
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
|
checked={subOrd === sub.ord}
|
||||||
subOrd === s.ord ? "text-accent" : "text-white"
|
label={sub.label}
|
||||||
|
onSelect={() => changeSubtitle(sub.ord)}
|
||||||
|
className={`${PLAYER_ROW} truncate ${
|
||||||
|
subOrd === sub.ord ? "text-accent" : "text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s.label}
|
{sub.label}
|
||||||
</Pressable>
|
</PopoverItem>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</PopoverSurface>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
|
{/* The fixed ⋯ (C5): the rare tools. Settings opens the tuning panel from the same spot,
|
||||||
@@ -1670,40 +1706,39 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
|||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="player"
|
size="player"
|
||||||
round
|
round
|
||||||
ref={menuTriggerRef}
|
ref={moreTriggerRef}
|
||||||
onClick={() => (menuOpen ? setMenuOpen(false) : setMoreOpen((o) => !o))}
|
{...morePop.getReferenceProps()}
|
||||||
aria-expanded={moreOpen || menuOpen}
|
aria-expanded={moreOpen || menuOpen}
|
||||||
label={t("plex.player.more")}
|
label={t("plex.player.more")}
|
||||||
>
|
>
|
||||||
<MoreHorizontal />
|
<MoreHorizontal />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{moreOpen && (
|
<PopoverSurface
|
||||||
<div
|
popover={morePop}
|
||||||
ref={moreRef}
|
ariaLabel={t("plex.player.more")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
className={`${PLAYER_MENU} w-60 p-1.5`}
|
||||||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 flex w-60 flex-col rounded-xl p-1.5 text-sm text-white shadow-2xl"
|
>
|
||||||
>
|
<div onClick={(e) => e.stopPropagation()} className="flex flex-col">
|
||||||
{moreItems.map((it) => (
|
{moreItems.map((it) => (
|
||||||
<Pressable
|
<PopoverItem
|
||||||
key={it.id}
|
key={it.id}
|
||||||
onClick={() => {
|
tone="media"
|
||||||
setMoreOpen(false);
|
label={it.label}
|
||||||
// Back on the ⋯ before the tool opens: its row is about to unmount, and a
|
// Closes first, so the focus is back on the ⋯ before the tool opens: a dialog
|
||||||
// dialog opened from here returns the focus to where it found it.
|
// opened from here returns the focus to where it found it.
|
||||||
menuTriggerRef.current?.focus();
|
onSelect={() => closeThenAct(() => setMoreOpen(false), it.run)}
|
||||||
it.run();
|
className={`${PLAYER_ROW} flex min-h-9 items-center gap-2.5 text-white`}
|
||||||
}}
|
|
||||||
className="touch-target flex min-h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-left hover:bg-white/10"
|
|
||||||
>
|
>
|
||||||
{it.icon}
|
{it.icon}
|
||||||
{it.label}
|
{it.label}
|
||||||
</Pressable>
|
</PopoverItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</PopoverSurface>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<PlexPlayerSettings
|
<PlexPlayerSettings
|
||||||
menuRef={menuRef}
|
menuRef={menuRef}
|
||||||
|
returnFocusTo={menuTriggerRef}
|
||||||
settingsTabs={settingsTabs}
|
settingsTabs={settingsTabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setSettingsTab={setSettingsTab}
|
setSettingsTab={setSettingsTab}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type RefObject } from "react";
|
import { useLayoutEffect, type RefObject } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Slider, Toggle } from "./plexPlayerControls";
|
import { Slider, Toggle } from "./plexPlayerControls";
|
||||||
@@ -18,6 +18,9 @@ export function PlexPlayerSettings(props: {
|
|||||||
// which is the honest shape — the element is null until the div mounts. The old `RefObject<T>` lied
|
// which is the honest shape — the element is null until the div mounts. The old `RefObject<T>` lied
|
||||||
// about that and 18's types let it.
|
// about that and 18's types let it.
|
||||||
menuRef: RefObject<HTMLDivElement | null>;
|
menuRef: RefObject<HTMLDivElement | null>;
|
||||||
|
/** The ⋯ that opened the panel: the focus goes back to it when the panel closes with the focus
|
||||||
|
* inside — by Escape, Back or the ⋯ itself — instead of falling to <body> (E28 review D3). */
|
||||||
|
returnFocusTo: RefObject<HTMLElement | null>;
|
||||||
settingsTabs: { key: SettingsTab; label: string }[];
|
settingsTabs: { key: SettingsTab; label: string }[];
|
||||||
activeTab: SettingsTab;
|
activeTab: SettingsTab;
|
||||||
setSettingsTab: (tab: SettingsTab) => void;
|
setSettingsTab: (tab: SettingsTab) => void;
|
||||||
@@ -28,6 +31,7 @@ export function PlexPlayerSettings(props: {
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
menuRef,
|
menuRef,
|
||||||
|
returnFocusTo,
|
||||||
settingsTabs,
|
settingsTabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
setSettingsTab,
|
setSettingsTab,
|
||||||
@@ -37,9 +41,23 @@ export function PlexPlayerSettings(props: {
|
|||||||
now,
|
now,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Into the panel on open (its first tab), and back to the ⋯ on close. The cleanup runs while the
|
||||||
|
// panel is still in the DOM, so "the focus was inside" is read before its removal drops it to
|
||||||
|
// <body>; focus the reader moved elsewhere (a click on the video) stays where they put it.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const panel = menuRef.current;
|
||||||
|
const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
|
||||||
|
panel?.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
|
||||||
|
return () => {
|
||||||
|
if (panel?.contains(document.activeElement))
|
||||||
|
queueMicrotask(() => back?.focus({ preventScroll: true }));
|
||||||
|
};
|
||||||
|
}, [menuRef, returnFocusTo]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t("plex.player.settings")}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ const HIGHLIGHT_SELECTED =
|
|||||||
"data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80";
|
"data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80";
|
||||||
const HIGHLIGHT_SELECTED_RING_ONLY =
|
const HIGHLIGHT_SELECTED_RING_ONLY =
|
||||||
"data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
|
"data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
|
||||||
|
// On the always-dark media chrome (a player's menus, `bg-media-chrome` under white text) the theme's
|
||||||
|
// `text-fg` is dark in the light theme: the highlighted row would go dark-on-dark. White instead.
|
||||||
|
const MEDIA_HIGHLIGHT =
|
||||||
|
"data-active:bg-white/15 data-active:text-white data-active:ring-1 data-active:ring-inset data-active:ring-white/40";
|
||||||
|
|
||||||
export function PopoverItem({
|
export function PopoverItem({
|
||||||
children,
|
children,
|
||||||
@@ -117,6 +121,7 @@ export function PopoverItem({
|
|||||||
elementRef,
|
elementRef,
|
||||||
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
|
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
|
||||||
label,
|
label,
|
||||||
|
tone,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -134,6 +139,8 @@ export function PopoverItem({
|
|||||||
* aria-haspopup/aria-expanded belong on that button — not on a wrapper div around it. */
|
* aria-haspopup/aria-expanded belong on that button — not on a wrapper div around it. */
|
||||||
elementRef?: (node: HTMLButtonElement | null) => void;
|
elementRef?: (node: HTMLButtonElement | null) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** `media`: the row sits on the always-dark media chrome (a player's menu), not a theme surface. */
|
||||||
|
tone?: "media";
|
||||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
|
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
|
||||||
const ctx = useContext(PopoverListContext);
|
const ctx = useContext(PopoverListContext);
|
||||||
const virtual = ctx?.virtual ?? false;
|
const virtual = ctx?.virtual ?? false;
|
||||||
@@ -212,11 +219,13 @@ export function PopoverItem({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
// touch-target: a menu row is a control primitive, 44 tall on a coarse pointer (A3).
|
// touch-target: a menu row is a control primitive, 44 tall on a coarse pointer (A3).
|
||||||
className={`outline-hidden touch-target ${
|
className={`outline-hidden touch-target ${
|
||||||
checked || selected
|
tone === "media"
|
||||||
? keepSelectedPaint
|
? MEDIA_HIGHLIGHT
|
||||||
? HIGHLIGHT_SELECTED_RING_ONLY
|
: checked || selected
|
||||||
: HIGHLIGHT_SELECTED
|
? keepSelectedPaint
|
||||||
: ROVING_HIGHLIGHT
|
? HIGHLIGHT_SELECTED_RING_ONLY
|
||||||
|
: HIGHLIGHT_SELECTED
|
||||||
|
: ROVING_HIGHLIGHT
|
||||||
}${className ? ` ${className}` : ""}`}
|
}${className ? ` ${className}` : ""}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const PLAIN: Record<string, [number, string]> = {
|
|||||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||||
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||||
"PlexPlayer.tsx": [4, "track picker rows and the ⋯ menu's rows"],
|
|
||||||
"PlexPlayerSettings.tsx": [2, "setting options"],
|
"PlexPlayerSettings.tsx": [2, "setting options"],
|
||||||
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
||||||
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
||||||
|
|||||||
@@ -46,8 +46,13 @@ describe("the Plex player's layers", () => {
|
|||||||
const popovers = classes(
|
const popovers = classes(
|
||||||
/\bbottom-full\b.*\bbg-media-chrome\b|\bbg-media-chrome\b.*\bbottom-full\b/,
|
/\bbottom-full\b.*\bbg-media-chrome\b|\bbg-media-chrome\b.*\bbottom-full\b/,
|
||||||
);
|
);
|
||||||
expect(popovers).toHaveLength(3); // tracks, the ⋯ menu (C5), the settings panel
|
expect(popovers).toHaveLength(1); // the settings panel, which renders inside the player
|
||||||
for (const c of popovers) expect(c).toMatch(/\bz-menu\b/);
|
for (const c of popovers) expect(c).toMatch(/\bz-menu\b/);
|
||||||
|
// The tracks list and the ⋯ menu are on the popover engine (E28 review D3), which lifts them to
|
||||||
|
// z-popover — above the player's own z-overlay — and into the fullscreen element.
|
||||||
|
expect([...src.matchAll(/<PopoverSurface\b[^>]*?className=\{`\$\{PLAYER_MENU\}/g)]).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the notices a level below", () => {
|
it("keeps the notices a level below", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user