import * as React from "react"; /** * Tabs — WAI-ARIA tabs pattern with roving tabindex and automatic activation. * * - Arrow keys move focus AND select; Home/End jump; focus wraps. * - Only the selected tab is in the Tab order (roving tabindex). * - Active indicator is a 2px bar (shape, not just color). * * Deep-linking: pass `paramKey` to sync the active tab to `?tab=`. * On mount we read it; on change we history.replaceState (no history spam). */ export type TabItem = { value: string; // URL-safe id label: string; disabled?: boolean; content: React.ReactNode; }; type TabsProps = { items: TabItem[]; defaultValue?: string; /** If set, sync selection to `?=value`. */ paramKey?: string; "aria-label": string; }; export function Tabs({ items, defaultValue, paramKey, "aria-label": ariaLabel }: TabsProps) { const enabled = items.filter((i) => !i.disabled); const initial = React.useMemo(() => { if (paramKey && typeof window !== "undefined") { const fromUrl = new URLSearchParams(window.location.search).get(paramKey); if (fromUrl && items.some((i) => i.value === fromUrl && !i.disabled)) return fromUrl; } return defaultValue ?? enabled[0]?.value; }, [defaultValue, paramKey, items, enabled]); const [selected, setSelected] = React.useState(initial); const tabRefs = React.useRef>({}); function select(value: string, moveFocus = false) { setSelected(value); if (moveFocus) tabRefs.current[value]?.focus(); if (paramKey && typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set(paramKey, value); window.history.replaceState(null, "", url); // deep-link without polluting history } } function onKeyDown(e: React.KeyboardEvent, index: number) { const idxs = items.map((_, i) => i).filter((i) => !items[i].disabled); const pos = idxs.indexOf(index); let next: number | null = null; switch (e.key) { case "ArrowRight": case "ArrowDown": next = idxs[(pos + 1) % idxs.length]; // wrap break; case "ArrowLeft": case "ArrowUp": next = idxs[(pos - 1 + idxs.length) % idxs.length]; // wrap break; case "Home": next = idxs[0]; break; case "End": next = idxs[idxs.length - 1]; break; default: return; } e.preventDefault(); if (next != null) select(items[next].value, true); // automatic activation } return (
{items.map((item, i) => { const isSelected = item.value === selected; return ( ); })}
{items.map((item) => ( ))}
); } /* -------------------------------------------------------------------------- */ /* Usage */ /* -------------------------------------------------------------------------- */ export function TabsExample() { return (
Edit your public profile.

}, { value: "billing", label: "Billing", content:

Manage your subscription and invoices.

}, { value: "team", label: "Team", content:

Invite teammates and set roles.

}, { value: "archived", label: "Archived", disabled: true, content:

No archived items.

}, ]} />
); }