'use client' import { useId, useRef, type KeyboardEvent, type ReactNode } from 'react' import { cn } from '../../lib/cn' import type { IconComponent } from '../../lib/types' export interface TabItem { value: T label: string icon?: IconComponent /** Count or status shown after the label. */ badge?: ReactNode disabled?: boolean content: ReactNode } export type TabsVariant = 'pill' | 'underline' export interface TabsProps { items: TabItem[] value: T onValueChange: (value: T) => void /** Accessible name for the tab list. */ label: string variant?: TabsVariant /** Stretch the tabs to fill the width. */ fullWidth?: boolean /** Merged last, so it wins. */ className?: string } /** * Tabs with the full keyboard model: arrows move, Home and End jump, and only * the selected tab is a tab stop, so Tab moves out to the panel rather than * through every tab. * * `pill` reuses the dashboard nav treatment; `underline` is for tabs inside a * card, where a filled pill would compete with the card header. */ export function Tabs({ items, value, onValueChange, label, variant = 'pill', fullWidth = false, className, }: TabsProps) { const base = useId() const refs = useRef<(HTMLButtonElement | null)[]>([]) const current = items.find((item) => item.value === value) ?? items[0] const onKeyDown = (event: KeyboardEvent, index: number) => { const keys = ['ArrowRight', 'ArrowLeft', 'Home', 'End'] if (!keys.includes(event.key)) return event.preventDefault() const enabled = items.map((item, i) => ({ item, i })).filter(({ item }) => !item.disabled) if (enabled.length === 0) return let target if (event.key === 'Home') target = enabled[0] else if (event.key === 'End') target = enabled[enabled.length - 1] else { const step = event.key === 'ArrowRight' ? 1 : -1 const position = enabled.findIndex(({ i }) => i === index) target = enabled[(position + step + enabled.length) % enabled.length] } onValueChange(target.item.value) refs.current[target.i]?.focus() } return (
{items.map((item, index) => { const selected = item.value === value const Icon = item.icon return ( ) })}
{current && (
{current.content}
)}
) }