'use client' import { useEffect, useRef, useState } from 'react' import { useIsomorphicLayoutEffect } from '../../lib/layout-effect' import { cn } from '../../lib/cn' import { Text } from '../Text' import type { IconComponent } from '../../lib/types' export interface MagicTabItem { value: string label: string icon?: IconComponent /** Small count shown after the label — unread, results, errors. */ count?: number disabled?: boolean } export type MagicTabsVariant = 'pill' | 'underline' export interface MagicTabsProps { items: MagicTabItem[] value: string onValueChange: (value: string) => void /** Accessible name for the tab list. */ label: string variant?: MagicTabsVariant /** Control height. */ size?: 'sm' | 'md' /** Spread the tabs across the full width. */ fullWidth?: boolean /** Ties the list to its panel — set the same id on the panel. */ panelId?: string /** Merged last, so it wins. */ className?: string } const SIZES = { sm: { tab: 'h-8 px-3 text-[12px]', gap: 'gap-0.5' }, md: { tab: 'h-10 px-4 text-[13px]', gap: 'gap-1' }, } as const /** * Tabs whose indicator physically slides from the old tab to the new one. * * `SegmentedControl` cross-fades a background; this measures. The indicator is * one absolutely positioned element that reads the selected tab's box and * animates `transform` and `width` to match — which is what lets it travel * across tabs of different widths, and what makes the movement legible as * *from here, to there* rather than as two things changing colour. * * The measurement is taken in `useLayoutEffect` so the indicator is never * painted in the wrong place for a frame, and re-taken by a `ResizeObserver`, * so it survives a font load, a container resize and a count appearing in a * label. A second, quieter indicator follows the pointer, so hovering shows * where a click would land before it lands there. * * Semantics are a real tab list: `role="tablist"`, roving `tabindex`, arrow * keys with Home and End, and `aria-selected` on the chosen tab. */ export function MagicTabs({ items, value, onValueChange, label, variant = 'pill', size = 'md', fullWidth = false, panelId, className, }: MagicTabsProps) { const listRef = useRef(null) const tabsRef = useRef<(HTMLButtonElement | null)[]>([]) const [indicator, setIndicator] = useState<{ x: number; width: number } | null>(null) const [hover, setHover] = useState<{ x: number; width: number } | null>(null) const selectedIndex = Math.max( 0, items.findIndex((item) => item.value === value), ) useIsomorphicLayoutEffect(() => { const measure = () => { const list = listRef.current const tab = tabsRef.current[selectedIndex] if (!list || !tab) return setIndicator({ x: tab.offsetLeft, width: tab.offsetWidth }) } measure() const list = listRef.current if (!list) return const observer = new ResizeObserver(measure) observer.observe(list) for (const tab of tabsRef.current) if (tab) observer.observe(tab) return () => observer.disconnect() }, [items, selectedIndex]) // Fonts land after the first paint and change every tab width with them. useEffect(() => { const fonts = (document as Document & { fonts?: FontFaceSet }).fonts if (!fonts) return let cancelled = false fonts.ready.then(() => { const tab = tabsRef.current[selectedIndex] if (!cancelled && tab) setIndicator({ x: tab.offsetLeft, width: tab.offsetWidth }) }) return () => { cancelled = true } }, [selectedIndex]) const focusTab = (index: number) => { const next = (index + items.length) % items.length tabsRef.current[next]?.focus() if (!items[next].disabled) onValueChange(items[next].value) } const onKeyDown = (event: React.KeyboardEvent) => { const keys = ['ArrowRight', 'ArrowLeft', 'Home', 'End'] if (!keys.includes(event.key)) return event.preventDefault() if (event.key === 'Home') focusTab(0) else if (event.key === 'End') focusTab(items.length - 1) else focusTab(selectedIndex + (event.key === 'ArrowRight' ? 1 : -1)) } const isPill = variant === 'pill' return (
setHover(null)} className={cn( 'relative inline-flex isolate', SIZES[size].gap, isPill && 'rounded-full border border-line bg-surface-muted p-1', !isPill && 'border-b border-line', fullWidth && 'flex w-full', className, )} > {/* The hover ghost, behind the real indicator. */}
) }