spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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