import { Fragment, useId, useRef, type KeyboardEvent } from "react"; import { panelId, tabId } from "./tabPanel"; import { type LucideIcon } from "lucide-react"; import CountBadge from "./CountBadge"; // 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, Stats): // a reload keeps the user where they were, 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()); 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 (
{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 ( {/* 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 && (
{heading}
)}
); })}
); }