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:
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user