'use client'; import { useId, useRef, useState } from 'react'; import { cn } from '@/lib/cn'; export type Tab = { id: string; label: React.ReactNode; badge?: React.ReactNode; disabled?: boolean }; /** * Uncontrolled until you pass `value`, so a simple panel switch needs no state * and a URL-driven one still works. * * Arrow keys move between tabs and skip disabled ones — a tablist that is only * clickable is not reachable for anyone navigating by keyboard, and the roving * tabindex below is what the ARIA pattern actually requires. */ export default function Tabs({ tabs, value, defaultValue, onChange, className, children, }: { tabs: Tab[]; value?: string; defaultValue?: string; onChange?: (id: string) => void; className?: string; /** Rendered below the tab strip — the caller owns which panel to show. */ children?: (activeId: string) => React.ReactNode; }) { const base = useId(); const [internal, setInternal] = useState(defaultValue ?? tabs[0]?.id); const active = value ?? internal; const refs = useRef>({}); const select = (id: string) => { if (value === undefined) setInternal(id); onChange?.(id); }; const onKey = (e: React.KeyboardEvent) => { const usable = tabs.filter((t) => !t.disabled); const i = usable.findIndex((t) => t.id === active); if (i === -1) return; const delta = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; if (!delta) return; e.preventDefault(); const next = usable[(i + delta + usable.length) % usable.length]; select(next.id); refs.current[next.id]?.focus(); }; return (
{tabs.map((t) => { const on = t.id === active; return ( ); })}
{children && (
{children(active)}
)}
); }