'use client'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { type ReactNode, useEffect, useId, useRef, useState } from 'react'; import { cn } from '@/lib/cn'; export type TabDef = { id: string; label: string; count?: number | null; hidden?: boolean }; /** * Accessible tabs driven by the URL: `?tab=` (default) — or `#` when `mode="hash"`. All panels are rendered * server-side (SEO: one canonical URL carries the whole page); only the visible one is displayed. Arrow keys move focus. * The tab strip scrolls horizontally on mobile with hidden scrollbar. */ export function Tabs({ tabs, defaultTab, children, mode = 'search', param = 'tab', className }: { tabs: TabDef[]; defaultTab?: string; children: ReactNode; mode?: 'search' | 'hash'; param?: string; className?: string }) { const visible = tabs.filter((t) => !t.hidden); const first = defaultTab ?? visible[0]?.id ?? ''; const router = useRouter(); const pathname = usePathname(); const sp = useSearchParams(); const [hash, setHash] = useState(''); const stripRef = useRef(null); const uid = useId(); useEffect(() => { if (mode !== 'hash') return; const read = () => setHash(window.location.hash.replace(/^#/, '')); read(); window.addEventListener('hashchange', read); return () => window.removeEventListener('hashchange', read); }, [mode]); const requested = mode === 'hash' ? hash : sp.get(param) ?? ''; const active = visible.some((t) => t.id === requested) ? requested : first; const select = (id: string) => { if (mode === 'hash') { window.history.replaceState(null, '', id === first ? pathname : `#${id}`); setHash(id); } else { const next = new URLSearchParams(sp.toString()); if (id === first) next.delete(param); else next.set(param, id); const q = next.toString(); router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); } }; useEffect(() => { const el = stripRef.current?.querySelector(`[data-tab="${active}"]`); el?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }, [active]); const onKey = (e: React.KeyboardEvent) => { const idx = visible.findIndex((t) => t.id === active); if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); const n = visible[(idx + (e.key === 'ArrowRight' ? 1 : visible.length - 1)) % visible.length]; if (n) { select(n.id); stripRef.current?.querySelector(`[data-tab="${n.id}"]`)?.focus(); } } }; return (
{visible.map((t) => { const on = t.id === active; return ( ); })}
{children}
); } import { createContext, useContext } from 'react'; const Ctx = createContext<{ active: string; uid: string }>({ active: '', uid: '' }); function TabsActive({ active, uid, children }: { active: string; uid: string; children: ReactNode }) { return {children}; } /** A panel inside . Always in the DOM; shown when its id is active. */ export function TabPanel({ id, children, className }: { id: string; children: ReactNode; className?: string }) { const { active, uid } = useContext(Ctx); const on = active === id; return ( ); }