SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
5.0 KB · 110 lines tsx
Raw Blame History
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