SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
4.7 KB · 113 lines tsx
Raw Blame History
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