spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1'use client';2import { usePathname, useRouter, useSearchParams } from 'next/navigation';3import { createContext, type ReactNode, useContext, useEffect, useId, useRef } from 'react';4import { cn } from '@/lib/cn';56export type TabDef = { id: string; label: string; count?: number | null; hidden?: boolean };78const Ctx = createContext<{ active: string; uid: string }>({ active: '', uid: '' });910/**11 * URL-driven tabs (`?tab=<id>`); all panels are rendered server-side, only the active one is shown. Arrow keys move.12 * The strip scrolls horizontally on mobile.13 */14export function Tabs({ tabs, defaultTab, children, param = 'tab', className, sticky = false }: { tabs: TabDef[]; defaultTab?: string; children: ReactNode; param?: string; className?: string; sticky?: boolean }) {15 const visible = tabs.filter((t) => !t.hidden);16 const first = defaultTab ?? visible[0]?.id ?? '';17 const router = useRouter();18 const pathname = usePathname();19 const sp = useSearchParams();20 const stripRef = useRef<HTMLDivElement>(null);21 const uid = useId();22 const requested = sp.get(param) ?? '';23 const active = visible.some((t) => t.id === requested) ? requested : first;2425 const select = (id: string) => {26 const next = new URLSearchParams(sp.toString());27 if (id === first) next.delete(param);28 else next.set(param, id);29 const q = next.toString();30 router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });31 };3233 useEffect(() => {34 // Horizontal reveal of the active tab only — never scrollIntoView (it scrolls the page vertically on load, spec: pages open at the top).35 const strip = stripRef.current;36 const el = strip?.querySelector<HTMLElement>(`[data-tab="${active}"]`);37 if (!strip || !el) return;38 const left = el.offsetLeft - (strip.clientWidth - el.offsetWidth) / 2;39 if (Math.abs(strip.scrollLeft - left) > 4) strip.scrollTo({ left: Math.max(0, left) });40 }, [active]);4142 const onKey = (e: React.KeyboardEvent) => {43 const idx = visible.findIndex((t) => t.id === active);44 if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {45 e.preventDefault();46 const n = visible[(idx + (e.key === 'ArrowRight' ? 1 : visible.length - 1)) % visible.length];47 if (n) {48 select(n.id);49 stripRef.current?.querySelector<HTMLElement>(`[data-tab="${n.id}"]`)?.focus();50 }51 }52 };5354 return (55 <div className={className} data-active-tab={active}>56 <div className={cn(sticky && 'sticky top-[var(--header-h)] z-30 -mx-4 bg-canvas/95 px-4 backdrop-blur-md md:mx-0 md:px-0')}>57 <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">58 {visible.map((t) => {59 const on = t.id === active;60 return (61 <button62 key={t.id}63 type="button"64 role="tab"65 id={`${uid}-tab-${t.id}`}66 data-tab={t.id}67 aria-selected={on}68 aria-controls={`${uid}-panel-${t.id}`}69 tabIndex={on ? 0 : -1}70 onClick={() => select(t.id)}71 className={cn('-mb-px flex h-11 shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 text-sm transition-colors first:pl-0', on ? 'border-ink font-medium text-ink' : 'border-transparent text-ink-2 hover:text-ink')}72 >73 {t.label}74 {t.count !== undefined && t.count !== null && <span className={cn('tnum text-[11px]', on ? 'text-ink-2' : 'text-ink-3')}>{t.count}</span>}75 </button>76 );77 })}78 </div>79 </div>80 <Ctx.Provider value={{ active, uid }}>{children}</Ctx.Provider>81 </div>82 );83}8485export function TabPanel({ id, children, className }: { id: string; children: ReactNode; className?: string }) {86 const { active, uid } = useContext(Ctx);87 const on = active === id;88 return (89 <div role="tabpanel" id={`${uid}-panel-${id}`} aria-labelledby={`${uid}-tab-${id}`} hidden={!on} className={cn('pt-5 md:pt-6', className)}>90 {children}91 </div>92 );93}9495/** Simple segmented control (not URL-bound) for local state like windows/kinds. */96export function Segmented<T extends string>({ options, value, onChange, className, size = 'md', ariaLabel }: { options: { id: T; label: string }[]; value: T; onChange: (v: T) => void; className?: string; size?: 'sm' | 'md'; ariaLabel?: string }) {97 return (98 <div role="radiogroup" aria-label={ariaLabel} className={cn('no-scrollbar inline-flex max-w-full overflow-x-auto rounded-[var(--radius)] border border-rule bg-surface p-0.5', className)}>99 {options.map((o) => {100 const on = o.id === value;101 return (102 <button key={o.id} type="button" role="radio" aria-checked={on} onClick={() => onChange(o.id)} className={cn('shrink-0 rounded-[3px] px-2.5 whitespace-nowrap transition-colors', size === 'sm' ? 'h-8 text-xs' : 'h-9 text-[13px]', on ? 'bg-ink text-canvas' : 'text-ink-2 hover:text-ink')}>103 {o.label}104 </button>105 );106 })}107 </div>108 );109}110