Settings, Configuration and Users name their tab (stab/cfgtab/utab); a link's tab wins and a tab pre-selected while its page is on screen now switches it. A playlist's sort, filters and grouping (plsort/plf/plgroup), the Files sort (fsort, always written) and the name searches of Channels, Files, Playlists and Plex (cq/fq/plq/pq+pqf) reopen from the address; the searches are written once the typing pauses.
149 lines
6.6 KiB
TypeScript
149 lines
6.6 KiB
TypeScript
import { Fragment, useId, useRef, type KeyboardEvent } from "react";
|
|
import { panelId, tabId } from "./tabPanel";
|
|
import { type LucideIcon } from "lucide-react";
|
|
import CountBadge from "./CountBadge";
|
|
import { MEDIA_CHOSEN } from "./mediaChrome";
|
|
|
|
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
|
|
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
|
|
// the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up.
|
|
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
|
|
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
|
|
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
|
|
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in
|
|
// the address: a reload keeps the user where they were, a link opens its tab, and a fresh tab opens on
|
|
// the first sub-tab. Not every
|
|
// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page
|
|
// and the player settings start on their first tab each time.
|
|
//
|
|
// An ARIA tab list, not a row of toggle buttons: `tablist` / `tab` / `aria-selected`,
|
|
// 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;
|
|
label: string;
|
|
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
|
|
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
|
|
testId?: string; // optional data-testid, to preserve existing e2e hooks
|
|
/** The vertical rail's heading this tab sits under (Configuration's sixteen groups under four).
|
|
* Drawn above the first tab of each run; tabs of one heading stay together. */
|
|
group?: string;
|
|
}
|
|
|
|
export default function Tabs({
|
|
tabs,
|
|
active,
|
|
onChange,
|
|
orientation = "horizontal",
|
|
className,
|
|
idBase,
|
|
label,
|
|
tone,
|
|
}: {
|
|
tabs: TabDef[];
|
|
active: string;
|
|
onChange: (id: string) => void;
|
|
// "vertical" gives the Settings-style side rail; "horizontal" (default) is the standard row.
|
|
orientation?: "horizontal" | "vertical";
|
|
// Override the row/rail wrapper — e.g. the channel manager embeds the rail in a justify-between
|
|
// header, or the settings rail adds a width + right border. Omit for the orientation default.
|
|
className?: string;
|
|
/** `useTabPanel().idBase`: the tabs then name the panel they control. */
|
|
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;
|
|
const refs = useRef(new Map<string, HTMLButtonElement>());
|
|
const vertical = orientation === "vertical";
|
|
const wrapCls =
|
|
className ??
|
|
(vertical ? "flex gap-1 overflow-x-auto sm:flex-col" : "flex flex-wrap gap-1 mb-4");
|
|
// Selection follows focus (automatic activation): every panel here renders at once, so the arrow
|
|
// can show what it lands on. An unknown `active` (still loading) leaves the first tab the stop.
|
|
const stop = tabs.some((tb) => tb.id === active) ? active : tabs[0]?.id;
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
const at = tabs.findIndex((tb) => tb.id === stop);
|
|
const to =
|
|
e.key === "ArrowRight" || e.key === "ArrowDown"
|
|
? (at + 1) % tabs.length
|
|
: e.key === "ArrowLeft" || e.key === "ArrowUp"
|
|
? (at - 1 + tabs.length) % tabs.length
|
|
: e.key === "Home"
|
|
? 0
|
|
: e.key === "End"
|
|
? tabs.length - 1
|
|
: -1;
|
|
const next = tabs[to];
|
|
if (!next || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
e.preventDefault();
|
|
refs.current.get(next.id)?.focus();
|
|
if (next.id !== active) onChange(next.id);
|
|
};
|
|
return (
|
|
<div
|
|
role="tablist"
|
|
aria-label={label}
|
|
aria-orientation={vertical ? "vertical" : "horizontal"}
|
|
onKeyDown={onKeyDown}
|
|
className={wrapCls}
|
|
>
|
|
{tabs.map((tb, i) => {
|
|
const on = tb.id === active;
|
|
const Icon = tb.icon;
|
|
const heading = vertical && tb.group && tb.group !== tabs[i - 1]?.group ? tb.group : null;
|
|
return (
|
|
<Fragment key={tb.id}>
|
|
{/* A heading, not a tab: no role a tab list would have to own, its text still read. Only
|
|
on the side rail; the phone's scrolling row has no room for it. */}
|
|
{heading && (
|
|
<div
|
|
role="presentation"
|
|
className="hidden px-3 pt-3 pb-1 text-2xs font-semibold tracking-wide text-muted uppercase first:pt-1 sm:block"
|
|
>
|
|
{heading}
|
|
</div>
|
|
)}
|
|
<button
|
|
ref={(node) => {
|
|
if (node) refs.current.set(tb.id, node);
|
|
else refs.current.delete(tb.id);
|
|
}}
|
|
type="button"
|
|
role="tab"
|
|
id={tabId(base, tb.id)}
|
|
aria-selected={on}
|
|
aria-controls={idBase ? panelId(idBase) : undefined}
|
|
tabIndex={tb.id === stop ? 0 : -1}
|
|
data-testid={tb.testId}
|
|
onClick={() => onChange(tb.id)}
|
|
className={
|
|
tone === "media"
|
|
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
|
on ? MEDIA_CHOSEN : "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}
|
|
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
|
|
</button>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|