diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx index 852c218..f0d9f89 100644 --- a/src/components/ui/Tabs.tsx +++ b/src/components/ui/Tabs.tsx @@ -1,7 +1,18 @@ "use client"; import { cn } from "@/lib/utils"; +import { useRef, useCallback, type KeyboardEvent } from "react"; +/** + * WAI-ARIA Tabs Pattern (Issue #24): + * - role="tablist" on container, role="tab" on each button, role="tabpanel" expected on consumer + * - aria-selected reflects active state + * - aria-controls links tab to its panel via id pairing + * - Roving tabindex: only active tab has tabIndex=0; others have tabIndex=-1 + * - Arrow Left/Right moves focus between tabs (wrapping) + * - Home/End jump to first/last tab + * - Activation is automatic on focus (WAI-ARIA recommended default for dashboards) + */ export function Tabs({ tabs, active, @@ -11,34 +22,100 @@ export function Tabs({ active: T; onChange: (key: T) => void; }) { + const tabRefs = useRef>(new Map()); + + const setTabRef = useCallback( + (key: string) => (el: HTMLButtonElement | null) => { + if (el) { + tabRefs.current.set(key, el); + } else { + tabRefs.current.delete(key); + } + }, + [], + ); + + const focusTab = useCallback((key: T) => { + tabRefs.current.get(key)?.focus(); + }, []); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const keys = tabs.map((t) => t.key); + const currentIndex = keys.indexOf(active); + let nextIndex: number | null = null; + + switch (e.key) { + case "ArrowRight": + nextIndex = (currentIndex + 1) % keys.length; + break; + case "ArrowLeft": + nextIndex = (currentIndex - 1 + keys.length) % keys.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = keys.length - 1; + break; + default: + return; + } + + e.preventDefault(); + const nextKey = keys[nextIndex]; + // Automatic activation: change tab AND move focus + onChange(nextKey); + focusTab(nextKey); + }, + [tabs, active, onChange, focusTab], + ); + return ( -
- {tabs.map((tab) => ( - - ))} +
+ {tabs.map((tab) => { + const isActive = active === tab.key; + const tabId = `tab-${tab.key}`; + const panelId = `panel-${tab.key}`; + + return ( + + ); + })}
); }