HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { usePathname, useRouter, useSearchParams } from 'next/navigation';3import { type ReactNode, useEffect, useId, useRef, useState } from 'react';4import { cn } from '@/lib/cn';56export type TabDef = { id: string; label: string; count?: number | null; hidden?: boolean };78/**9 * Accessible tabs driven by the URL: `?tab=<id>` (default) — or `#<id>` when `mode="hash"`. All panels are rendered10 * server-side (SEO: one canonical URL carries the whole page); only the visible one is displayed. Arrow keys move focus.11 * The tab strip scrolls horizontally on mobile with hidden scrollbar.12 */13export function Tabs({ tabs, defaultTab, children, mode = 'search', param = 'tab', className }: { tabs: TabDef[]; defaultTab?: string; children: ReactNode; mode?: 'search' | 'hash'; param?: string; className?: string }) {14 const visible = tabs.filter((t) => !t.hidden);15 const first = defaultTab ?? visible[0]?.id ?? '';16 const router = useRouter();17 const pathname = usePathname();18 const sp = useSearchParams();19 const [hash, setHash] = useState<string>('');20 const stripRef = useRef<HTMLDivElement>(null);21 const uid = useId();2223 useEffect(() => {24 if (mode !== 'hash') return;25 const read = () => setHash(window.location.hash.replace(/^#/, ''));26 read();27 window.addEventListener('hashchange', read);28 return () => window.removeEventListener('hashchange', read);29 }, [mode]);3031 const requested = mode === 'hash' ? hash : sp.get(param) ?? '';32 const active = visible.some((t) => t.id === requested) ? requested : first;3334 const select = (id: string) => {35 if (mode === 'hash') {36 window.history.replaceState(null, '', id === first ? pathname : `#${id}`);37 setHash(id);38 } else {39 const next = new URLSearchParams(sp.toString());40 if (id === first) next.delete(param);41 else next.set(param, id);42 const q = next.toString();43 router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });44 }45 };4647 useEffect(() => {48 const el = stripRef.current?.querySelector<HTMLElement>(`[data-tab="${active}"]`);49 el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });50 }, [active]);5152 const onKey = (e: React.KeyboardEvent) => {53 const idx = visible.findIndex((t) => t.id === active);54 if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {55 e.preventDefault();56 const n = visible[(idx + (e.key === 'ArrowRight' ? 1 : visible.length - 1)) % visible.length];57 if (n) {58 select(n.id);59 stripRef.current?.querySelector<HTMLElement>(`[data-tab="${n.id}"]`)?.focus();60 }61 }62 };6364 return (65 <div className={className} data-active-tab={active}>66 <div ref={stripRef} role="tablist" aria-label="Sections" onKeyDown={onKey} className="no-scrollbar -mx-4 flex overflow-x-auto border-b border-rule px-4 md:mx-0 md:px-0">67 {visible.map((t) => {68 const on = t.id === active;69 return (70 <button71 key={t.id}72 type="button"73 role="tab"74 id={`${uid}-tab-${t.id}`}75 data-tab={t.id}76 aria-selected={on}77 aria-controls={`${uid}-panel-${t.id}`}78 tabIndex={on ? 0 : -1}79 onClick={() => select(t.id)}80 className={cn('-mb-px flex h-11 shrink-0 items-center gap-1.5 border-b-2 px-3 text-sm whitespace-nowrap transition-colors first:pl-0', on ? 'border-ink text-ink font-medium' : 'border-transparent text-ink-2 hover:text-ink')}81 >82 {t.label}83 {t.count !== undefined && t.count !== null && <span className={cn('tnum text-[11px]', on ? 'text-ink-2' : 'text-ink-3')}>{t.count}</span>}84 </button>85 );86 })}87 </div>88 <div data-tabs-panels="" className="[&>[data-tab-panel]]:hidden [&>[data-tab-panel][data-active=true]]:block">89 <TabsActive active={active} uid={uid}>90 {children}91 </TabsActive>92 </div>93 </div>94 );95}9697import { createContext, useContext } from 'react';98const Ctx = createContext<{ active: string; uid: string }>({ active: '', uid: '' });99function TabsActive({ active, uid, children }: { active: string; uid: string; children: ReactNode }) {100 return <Ctx.Provider value={{ active, uid }}>{children}</Ctx.Provider>;101}102103/** A panel inside <Tabs>. Always in the DOM; shown when its id is active. */104export function TabPanel({ id, children, className }: { id: string; children: ReactNode; className?: string }) {105 const { active, uid } = useContext(Ctx);106 const on = active === id;107 return (108 <div role="tabpanel" id={`${uid}-panel-${id}`} aria-labelledby={`${uid}-tab-${id}`} data-tab-panel="" data-active={on} hidden={!on} className={cn('pt-6', className)}>109 {children}110 </div>111 );112}113