/** * @file PageTabs.tsx * @description Shared top-of-page tab bar used by the pages that merged several * former routes into one (Settings hub, Sessions/Events history). The owning * page keeps the active tab in its own query param. `actions` sits at the bar's * right edge, outside the tablist. * * These are real in-page tabs (one view swapped in place), so they follow the * WAI-ARIA tabs pattern: one tab stop (roving tabindex), Left/Right/Home/End * move AND select. Pass the active view as `children` and it is wrapped in the * `role="tabpanel"` the tabs point at with `aria-controls`; without children no * `aria-controls` is emitted (it would name an element that does not exist). * The selected tab is marked by weight and an underline, not colour alone. * @author Nguyễn Ngọc Trí Vĩ */ import { useId, type ComponentType, type KeyboardEvent, type ReactNode } from "react"; export interface PageTab { id: string; label: string; Icon: ComponentType<{ className?: string }>; } interface PageTabsProps { tabs: PageTab[]; activeId: string; onSelect: (id: string) => void; ariaLabel: string; /** Buttons at the right edge of the bar (not tabs). */ actions?: ReactNode; /** The active tab's view, rendered inside its `role="tabpanel"`. */ children?: ReactNode; } export function PageTabs({ tabs, activeId, onSelect, ariaLabel, actions, children, }: PageTabsProps) { const base = useId(); const tabId = (id: string) => `${base}-tab-${id}`; const panelId = `${base}-panel`; const hasPanel = children !== undefined; const onKeyDown = (e: KeyboardEvent) => { const i = tabs.findIndex((t) => t.id === activeId); const last = tabs.length - 1; const next = e.key === "ArrowRight" ? (i + 1) % tabs.length : e.key === "ArrowLeft" ? (i - 1 + tabs.length) % tabs.length : e.key === "Home" ? 0 : e.key === "End" ? last : -1; const tab = tabs[next]; if (!tab) return; e.preventDefault(); onSelect(tab.id); document.getElementById(tabId(tab.id))?.focus(); }; const list = (
{tabs.map(({ id, label, Icon }) => { const isActive = id === activeId; return ( ); })}
); const bar = actions ? (
{list}
{actions}
) : ( list ); if (!hasPanel) return bar; return ( <> {bar}
{children}
); }