fix(plex): the player's settings panel is a tab list with named, touch-sized rows

- Its section switch is `ui/Tabs` (new `tone="media"` paint for the dark chrome) with a wired
  tabpanel; the panel opens with the focus on the selected tab.
- On/off rows are `CheckRow` (the whole row, 44 on touch), the "no background" checkbox too.
- Every slider and the four colour wells carry the name their row shows.
- The clock's date-style buttons reach the touch target.
This commit is contained in:
2026-09-27 14:38:27 +02:00
parent 76eead976f
commit a7d41eb5f3
5 changed files with 327 additions and 212 deletions
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
//
// E28 review D5 + L5 + D6: the Plex player's settings panel. Its section switch was a row of
// `aria-pressed` buttons (now an ARIA tab list on `ui/Tabs`), its rows sat under the 44 touch target,
// its on/off rows were a hand-laid label (now `CheckRow`), and its sliders and colour wells had no
// accessible name — the visible label beside them was not tied to them.
import { useRef, useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k }),
}));
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { DEFAULT_PREFS, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
afterEach(cleanup);
type Tab = "sync" | "playback" | "subtitle" | "clock";
const TABS: { key: Tab; label: string }[] = [
{ key: "sync", label: "Sync" },
{ key: "playback", label: "Playback" },
{ key: "subtitle", label: "Subtitles" },
{ key: "clock", label: "Clock" },
];
function Harness({ start = "sync" as Tab, prefs: initial = {} as Partial<PlexPlayerPrefs> }) {
const [tab, setTab] = useState<Tab>(start);
const [prefs, setPrefs] = useState<PlexPlayerPrefs>({ ...DEFAULT_PREFS, ...initial });
const menuRef = useRef<HTMLDivElement>(null);
const back = useRef<HTMLElement>(null);
return (
<PlexPlayerSettings
menuRef={menuRef}
returnFocusTo={back}
settingsTabs={TABS}
activeTab={tab}
setSettingsTab={setTab}
prefs={prefs}
patchPrefs={(p) => setPrefs((cur) => ({ ...cur, ...p }))}
changeAudioOffset={() => {}}
now={new Date(2026, 8, 27, 12, 0)}
/>
);
}
const tab = (name: string) => screen.getByRole("tab", { name });
describe("the Plex player's settings panel", () => {
it("switches its sections as a tab list, the focus on the selected tab", () => {
render(<Harness start="subtitle" />);
expect(screen.getByRole("tablist")).toBeTruthy();
expect(document.activeElement).toBe(tab("Subtitles"));
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
expect(tab("Clock").getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tabpanel", { name: "Clock" })).toBeTruthy();
});
it("gives every slider and colour well the name its row shows", () => {
render(<Harness start="subtitle" prefs={{ subBg: "#000000", subShadow: 2 }} />);
for (const name of ["plex.player.subSize", "plex.player.subPos", "plex.player.subShadow"])
expect(screen.getByRole("slider", { name })).toBeTruthy();
for (const name of ["plex.player.subColor", "plex.player.subBg", "plex.player.subShadowColor"])
expect(screen.getByLabelText(name).getAttribute("type")).toBe("color");
fireEvent.click(tab("Clock"));
expect(screen.getByLabelText("plex.player.clockColor").getAttribute("type")).toBe("color");
});
it("lays its on/off rows on CheckRow and its buttons at the touch target", () => {
render(<Harness start="clock" prefs={{ clockDate: true }} />);
const row = screen.getByRole("checkbox", { name: "plex.player.clockShow" }).closest("label")!;
expect(row.className).toContain("pointer-coarse:min-h-(--touch-target)");
for (const b of screen.getAllByRole("button")) expect(b.className).toContain("touch-target");
for (const t of screen.getAllByRole("tab")) expect(t.className).toContain("touch-target");
fireEvent.click(tab("Subtitles"));
const none = screen.getByRole("checkbox", { name: "plex.player.subBgNone" }).closest("label")!;
expect(none.className).toContain("pointer-coarse:min-h-(--touch-target)");
});
});
+212 -200
View File
@@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next";
import { Slider, Toggle } from "./plexPlayerControls";
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
import { Pressable } from "./ui/Pressable";
import { ColorInput, Checkbox } from "./ui/NativeControls";
import { ColorInput, CheckRow } from "./ui/NativeControls";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
@@ -41,15 +43,18 @@ export function PlexPlayerSettings(props: {
now,
} = props;
const { t } = useTranslation();
// Into the panel on open (its first tab), and back to the ⋯ on close. The cleanup runs while the
const panel = useTabPanel();
// Into the panel on open (its selected 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 root = menuRef.current;
const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
panel?.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
root
?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
?.focus({ preventScroll: true });
return () => {
if (panel?.contains(document.activeElement))
if (root?.contains(document.activeElement))
queueMicrotask(() => back?.focus({ preventScroll: true }));
};
}, [menuRef, returnFocusTo]);
@@ -62,218 +67,225 @@ export function PlexPlayerSettings(props: {
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"
>
<div className="mb-1.5 flex gap-1">
{settingsTabs.map((tab) => (
<Pressable
key={tab.key}
align="center"
aria-pressed={activeTab === tab.key}
onClick={() => setSettingsTab(tab.key)}
className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-2xs ${
activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`}
>
{tab.label}
</Pressable>
))}
</div>
{activeTab === "sync" && (
<>
<Slider
label={t("plex.player.subOffset")}
value={prefs.subOffset}
min={-10}
max={10}
step={0.1}
suffix="s"
onChange={(v) => patchPrefs({ subOffset: v })}
onReset={() => patchPrefs({ subOffset: 0 })}
/>
<Slider
label={t("plex.player.audioOffset")}
value={prefs.audioOffset}
min={-5}
max={5}
step={0.1}
suffix="s"
onChange={changeAudioOffset}
onReset={() => changeAudioOffset(0)}
/>
</>
)}
{activeTab === "playback" && (
<>
<Slider
label={t("plex.player.seekStep")}
value={prefs.seekStep}
min={5}
max={30}
step={1}
suffix="s"
onChange={(v) => patchPrefs({ seekStep: v })}
/>
<Slider
label={t("plex.player.fineSeek")}
value={prefs.fineSeekStep}
min={0.1}
max={5}
step={0.1}
suffix="s"
onChange={(v) => patchPrefs({ fineSeekStep: v })}
/>
<Toggle
label={t("plex.player.autoHide")}
checked={prefs.barAutoHide}
onChange={(v) => patchPrefs({ barAutoHide: v })}
/>
<Toggle
label={t("plex.player.autoSkipIntro")}
checked={prefs.autoSkipIntro}
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
/>
<Toggle
label={t("plex.player.autoSkipCredits")}
checked={prefs.autoSkipCredits}
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
/>
<Toggle
label={t("plex.player.upNext.pref")}
checked={prefs.upNext}
onChange={(v) => patchPrefs({ upNext: v })}
/>
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
<Tabs
tone="media"
idBase={panel.idBase}
tabs={settingsTabs.map((tab) => ({ id: tab.key, label: tab.label }))}
active={activeTab}
onChange={(id) => setSettingsTab(id as SettingsTab)}
className="mb-1.5 flex gap-1"
/>
<div {...panel.panelProps(activeTab)}>
{activeTab === "sync" && (
<>
<Slider
label={t("plex.player.autoSkipDelay")}
value={prefs.autoSkipDelay}
min={0}
label={t("plex.player.subOffset")}
value={prefs.subOffset}
min={-10}
max={10}
step={0.1}
suffix="s"
onChange={(v) => patchPrefs({ subOffset: v })}
onReset={() => patchPrefs({ subOffset: 0 })}
/>
<Slider
label={t("plex.player.audioOffset")}
value={prefs.audioOffset}
min={-5}
max={5}
step={0.1}
suffix="s"
onChange={changeAudioOffset}
onReset={() => changeAudioOffset(0)}
/>
</>
)}
{activeTab === "playback" && (
<>
<Slider
label={t("plex.player.seekStep")}
value={prefs.seekStep}
min={5}
max={30}
step={1}
suffix="s"
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
onChange={(v) => patchPrefs({ seekStep: v })}
/>
)}
</>
)}
<Slider
label={t("plex.player.fineSeek")}
value={prefs.fineSeekStep}
min={0.1}
max={5}
step={0.1}
suffix="s"
onChange={(v) => patchPrefs({ fineSeekStep: v })}
/>
<Toggle
label={t("plex.player.autoHide")}
checked={prefs.barAutoHide}
onChange={(v) => patchPrefs({ barAutoHide: v })}
/>
<Toggle
label={t("plex.player.autoSkipIntro")}
checked={prefs.autoSkipIntro}
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
/>
<Toggle
label={t("plex.player.autoSkipCredits")}
checked={prefs.autoSkipCredits}
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
/>
<Toggle
label={t("plex.player.upNext.pref")}
checked={prefs.upNext}
onChange={(v) => patchPrefs({ upNext: v })}
/>
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
<Slider
label={t("plex.player.autoSkipDelay")}
value={prefs.autoSkipDelay}
min={0}
max={10}
step={1}
suffix="s"
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
/>
)}
</>
)}
{activeTab === "subtitle" && (
<>
<Slider
label={t("plex.player.subSize")}
value={prefs.subSize}
min={50}
max={250}
step={5}
suffix="%"
onChange={(v) => patchPrefs({ subSize: v })}
/>
<Slider
label={t("plex.player.subPos")}
value={prefs.subPos}
min={50}
max={95}
step={1}
suffix="%"
onChange={(v) => patchPrefs({ subPos: v })}
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{t("plex.player.subColor")}</span>
<ColorInput value={prefs.subColor} onChange={(v) => patchPrefs({ subColor: v })} />
</div>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{t("plex.player.subBg")}</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-2xs text-white/70">
<Checkbox
{activeTab === "subtitle" && (
<>
<Slider
label={t("plex.player.subSize")}
value={prefs.subSize}
min={50}
max={250}
step={5}
suffix="%"
onChange={(v) => patchPrefs({ subSize: v })}
/>
<Slider
label={t("plex.player.subPos")}
value={prefs.subPos}
min={50}
max={95}
step={1}
suffix="%"
onChange={(v) => patchPrefs({ subPos: v })}
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{t("plex.player.subColor")}</span>
<ColorInput
value={prefs.subColor}
aria-label={t("plex.player.subColor")}
onChange={(v) => patchPrefs({ subColor: v })}
/>
</div>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{t("plex.player.subBg")}</span>
<div className="flex items-center gap-2">
<CheckRow
checked={prefs.subBg === "transparent"}
onChange={(checked) =>
patchPrefs({
subBg: checked ? "transparent" : "#000000",
})
}
/>
{t("plex.player.subBgNone")}
</label>
{prefs.subBg !== "transparent" && (
<ColorInput value={prefs.subBg} onChange={(v) => patchPrefs({ subBg: v })} />
)}
className="gap-1 text-2xs text-white/70"
>
{t("plex.player.subBgNone")}
</CheckRow>
{prefs.subBg !== "transparent" && (
<ColorInput
value={prefs.subBg}
aria-label={t("plex.player.subBg")}
onChange={(v) => patchPrefs({ subBg: v })}
/>
)}
</div>
</div>
</div>
<Slider
label={t("plex.player.subShadow")}
value={prefs.subShadow}
min={0}
max={10}
step={1}
suffix="px"
onChange={(v) => patchPrefs({ subShadow: v })}
/>
{prefs.subShadow > 0 && (
<Slider
label={t("plex.player.subShadow")}
value={prefs.subShadow}
min={0}
max={10}
step={1}
suffix="px"
onChange={(v) => patchPrefs({ subShadow: v })}
/>
{prefs.subShadow > 0 && (
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">
{t("plex.player.subShadowColor")}
</span>
<ColorInput
value={prefs.subShadowColor}
aria-label={t("plex.player.subShadowColor")}
onChange={(v) => patchPrefs({ subShadowColor: v })}
/>
</div>
)}
</>
)}
{activeTab === "clock" && (
<>
<Toggle
label={t("plex.player.clockShow")}
checked={prefs.clockShow}
onChange={(v) => patchPrefs({ clockShow: v })}
/>
<Toggle
label={t("plex.player.clock24h")}
checked={prefs.clock24h}
onChange={(v) => patchPrefs({ clock24h: v })}
/>
<Slider
label={t("plex.player.clockSize")}
value={prefs.clockSize}
min={70}
max={200}
step={5}
suffix="%"
onChange={(v) => patchPrefs({ clockSize: v })}
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">
{t("plex.player.subShadowColor")}
</span>
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
<ColorInput
value={prefs.subShadowColor}
onChange={(v) => patchPrefs({ subShadowColor: v })}
value={prefs.clockColor}
aria-label={t("plex.player.clockColor")}
onChange={(v) => patchPrefs({ clockColor: v })}
/>
</div>
)}
</>
)}
{activeTab === "clock" && (
<>
<Toggle
label={t("plex.player.clockShow")}
checked={prefs.clockShow}
onChange={(v) => patchPrefs({ clockShow: v })}
/>
<Toggle
label={t("plex.player.clock24h")}
checked={prefs.clock24h}
onChange={(v) => patchPrefs({ clock24h: v })}
/>
<Slider
label={t("plex.player.clockSize")}
value={prefs.clockSize}
min={70}
max={200}
step={5}
suffix="%"
onChange={(v) => patchPrefs({ clockSize: v })}
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
<ColorInput value={prefs.clockColor} onChange={(v) => patchPrefs({ clockColor: v })} />
</div>
<Toggle
label={t("plex.player.clockDate")}
checked={prefs.clockDate}
onChange={(v) => patchPrefs({ clockDate: v })}
/>
{prefs.clockDate && (
<div className="flex gap-1 px-1 py-1">
{(["hu", "en"] as const).map((st) => (
<Pressable
key={st}
align="center"
aria-pressed={prefs.clockDateStyle === st}
onClick={() => patchPrefs({ clockDateStyle: st })}
className={`flex-1 rounded px-2 py-1 text-2xs ${
prefs.clockDateStyle === st
? "bg-white/15 text-accent"
: "text-white/70 hover:bg-white/10"
}`}
>
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
</Pressable>
))}
</div>
)}
</>
)}
<Toggle
label={t("plex.player.clockDate")}
checked={prefs.clockDate}
onChange={(v) => patchPrefs({ clockDate: v })}
/>
{prefs.clockDate && (
<div className="flex gap-1 px-1 py-1">
{(["hu", "en"] as const).map((st) => (
<Pressable
key={st}
align="center"
aria-pressed={prefs.clockDateStyle === st}
onClick={() => patchPrefs({ clockDateStyle: st })}
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
prefs.clockDateStyle === st
? "bg-white/15 text-accent"
: "text-white/70 hover:bg-white/10"
}`}
>
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
</Pressable>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
+17 -6
View File
@@ -2,7 +2,7 @@ import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { RotateCcw } from "lucide-react";
import { IconButton } from "./ui/IconButton";
import { Slider as Range, Checkbox } from "./ui/NativeControls";
import { Slider as Range, CheckRow } from "./ui/NativeControls";
// --- settings-panel building blocks ------------------------------------------------------------
export function PanelHead({ children }: { children: ReactNode }) {
@@ -51,7 +51,14 @@ export function Slider({
)}
</span>
</div>
<Range min={min} max={max} step={step} value={value} onChange={(v) => onChange(v)} />
<Range
min={min}
max={max}
step={step}
value={value}
aria-label={label}
onChange={(v) => onChange(v)}
/>
</div>
);
}
@@ -64,11 +71,15 @@ export function Toggle({
checked: boolean;
onChange: (v: boolean) => void;
}) {
// The whole row toggles and grows to 44 on touch (CheckRow); the box sits at the row's end.
return (
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{label}</span>
<Checkbox checked={checked} onChange={onChange} />
</label>
<CheckRow
checked={checked}
onChange={onChange}
className="w-full flex-row-reverse justify-between px-1 py-1.5 text-[0.75rem] text-white/80"
>
{label}
</CheckRow>
);
}
+16 -5
View File
@@ -15,6 +15,8 @@ import CountBadge from "./CountBadge";
// ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the
// vertical rail is a row below `sm`), Home/End jump to the ends. The panel it controls is wired with
// `useTabPanel`: the tabs point at it (`aria-controls`) and it is labelled by the selected tab.
// `tone="media"` is the same list painted for the player's dark chrome (the Plex player's settings
// panel), which looks the same on every theme because the picture under it does.
export interface TabDef {
id: string;
@@ -32,6 +34,7 @@ export default function Tabs({
className,
idBase,
label,
tone,
}: {
tabs: TabDef[];
active: string;
@@ -45,6 +48,8 @@ export default function Tabs({
idBase?: string;
/** The tab list's accessible name, when the page heading beside it does not say what it switches. */
label?: string;
/** Painted for the media chrome instead of the page (equal-width, white on black). */
tone?: "media";
}) {
const ownBase = useId();
const base = idBase ?? ownBase;
@@ -100,11 +105,17 @@ export default function Tabs({
tabIndex={tb.id === stop ? 0 : -1}
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
className={`pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`}
className={
tone === "media"
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`
: `pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`
}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
@@ -27,7 +27,7 @@ const PLAIN: Record<string, [number, string]> = {
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"],
"PlexPlayerSettings.tsx": [2, "setting options"],
"PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
"PlexSearchBox.tsx": [1, "a listbox option"],