SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
4.0 KB · 106 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { createContext, useContext, useId, useState, type KeyboardEvent, type ReactNode } from "react";5import { cn } from "./cn";67interface TabsCtx {8  value: string;9  setValue: (v: string) => void;10  id: string;11}12const Ctx = createContext<TabsCtx | null>(null);1314/** Underline tabs — controlled or uncontrolled. Arrow keys move focus/selection. */15export function Tabs({ value, defaultValue, onValueChange, children, className }: { value?: string; defaultValue?: string; onValueChange?: (v: string) => void; children: ReactNode; className?: string }) {16  const [inner, setInner] = useState(defaultValue ?? "");17  const id = useId();18  const v = value ?? inner;19  const set = (nv: string) => {20    setInner(nv);21    onValueChange?.(nv);22  };23  return (24    <Ctx.Provider value={{ value: v, setValue: set, id }}>25      <div className={className}>{children}</div>26    </Ctx.Provider>27  );28}2930export function TabList({ children, className, label }: { children: ReactNode; className?: string; label?: string }) {31  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {32    if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;33    const tabs = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]'));34    const i = tabs.findIndex((t) => t === document.activeElement);35    if (i < 0) return;36    const n = tabs[(i + (e.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length];37    n?.focus();38    n?.click();39    e.preventDefault();40  };41  return (42    <div role="tablist" aria-label={label} onKeyDown={onKey} className={cn("-mx-3 flex gap-0 overflow-x-auto border-b border-hair px-3 no-scrollbar sm:mx-0 sm:px-0", className)}>43      {children}44    </div>45  );46}4748export function Tab({ value, children, count, className }: { value: string; children: ReactNode; count?: number | null; className?: string }) {49  const ctx = useContext(Ctx);50  if (!ctx) throw new Error("Tab outside Tabs");51  const active = ctx.value === value;52  return (53    <button54      type="button"55      role="tab"56      id={`${ctx.id}-tab-${value}`}57      aria-selected={active}58      aria-controls={`${ctx.id}-panel-${value}`}59      tabIndex={active ? 0 : -1}60      onClick={() => ctx.setValue(value)}61      className={cn(62        "-mb-px inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 text-[13px] font-medium transition-colors first:pl-0",63        active ? "border-ink text-ink" : "border-transparent text-ink-3 hover:text-ink",64        className,65      )}66    >67      {children}68      {count !== undefined && count !== null && <span className="figure text-[11px] text-ink-3">{count}</span>}69    </button>70  );71}7273export function TabPanel({ value, children, className }: { value: string; children: ReactNode; className?: string }) {74  const ctx = useContext(Ctx);75  if (!ctx) throw new Error("TabPanel outside Tabs");76  if (ctx.value !== value) return null;77  return (78    <div role="tabpanel" id={`${ctx.id}-panel-${value}`} aria-labelledby={`${ctx.id}-tab-${value}`} className={cn("pt-3", className)}>79      {children}80    </div>81  );82}8384/** Link-based tabs (server-friendly; state lives in the URL). */85export function LinkTabs({ items, className }: { items: Array<{ href: string; label: ReactNode; active: boolean; count?: number | null }>; className?: string }) {86  return (87    <nav className={cn("-mx-3 flex gap-0 overflow-x-auto border-b border-hair px-3 no-scrollbar sm:mx-0 sm:px-0", className)}>88      {items.map((it, i) => (89        <Link90          key={i}91          href={it.href}92          aria-current={it.active ? "page" : undefined}93          scroll={false}94          className={cn(95            "-mb-px inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 text-[13px] font-medium transition-colors first:pl-0",96            it.active ? "border-ink text-ink" : "border-transparent text-ink-3 hover:text-ink",97          )}98        >99          {it.label}100          {it.count !== undefined && it.count !== null && <span className="figure text-[11px] text-ink-3">{it.count}</span>}101        </Link>102      ))}103    </nav>104  );105}106