/** * Pure id + keyboard helpers for the admin panel-tabs pattern (WAI-ARIA Tabs). * * Kept React-free so the id contract and the key resolver can be tested and * reused without mounting anything. `PanelTabs.tsx` is the only renderer. */ import { resolveRovingIndex } from "../../frontend/shared/rovingFocus"; export interface PanelTabDescriptor { id: string; label: string; } /** id of the `role="tab"` button for `tabId` within the `baseId` tablist. */ export function tabControlId(baseId: string, tabId: string): string { return `${baseId}-tab-${tabId}`; } /** id of the `role="tabpanel"` element for `tabId` within the `baseId` tablist. */ export function tabPanelId(baseId: string, tabId: string): string { return `${baseId}-panel-${tabId}`; } /** * Props to spread onto a panel's existing wrapper element. Panels are mounted * only while active, so the tab's `aria-controls` is likewise only set while * the panel exists — see `PanelTabs`. */ export function tabPanelProps( baseId: string, tabId: string, ): { role: "tabpanel"; id: string; "aria-labelledby": string } { return { role: "tabpanel", id: tabPanelId(baseId, tabId), "aria-labelledby": tabControlId(baseId, tabId), }; } /** * Pure keyboard resolver for a horizontal tablist. Returns the tab id that * should become active+focused, or null for keys we do not own. * * Deliberately narrower than the radio-group resolver: a horizontal tablist * must NOT swallow ArrowUp/ArrowDown (the user is scrolling the panel), and * Enter/Space already activate the focused tab through its native click. * Movement wraps at both ends; horizontal arrows are RTL-aware. */ export function resolvePanelTabKey( key: string, currentId: string, tabIds: readonly string[], rtl = false, ): string | null { const count = tabIds.length; if (count === 0) return null; const targetIndex = resolveRovingIndex( key, tabIds.indexOf(currentId), count, "horizontal", rtl, ); return targetIndex === null ? null : tabIds[targetIndex]; }