Files
siftlode/frontend/src/components/ui/Tabs.tsx
T
peter d4f24de4ba feat(nav): tabs, list views and name searches are named in the address
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.
2026-10-26 01:13:39 +01:00

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>
);
}