1"use client";
2
3import React, { useState, useRef, useEffect, useLayoutEffect, useId } from "react";
4
5interface Tab {
6 key: string;
7 label: string;
8 content?: React.ReactNode;
9}
10
11interface TabsProps {
12 tabs: Tab[];
13 defaultTab?: string;
14 onChange?: (key: string) => void;
15 className?: string;
16}
17
18/* A sunken well holding a raised thumb — the same segmented control as
19 Pagination. One Tab stop; arrows move between tabs and select them. */
20export function Tabs({ tabs, defaultTab, onChange, className = "" }: TabsProps) {
21 const [active, setActive] = useState(defaultTab ?? tabs[0]?.key ?? "");
22 const [direction, setDirection] = useState<"left" | "right">("right");
23 const [pill, setPill] = useState({ left: 0, width: 0, ready: false });
24 const listRef = useRef<HTMLDivElement>(null);
25 const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
26 const uid = useId();
27
28 const measurePill = (key: string) => {
29 const btn = btnRefs.current[key];
30 const list = listRef.current;
31 if (!btn || !list) return;
32 const lr = list.getBoundingClientRect();
33 const br = btn.getBoundingClientRect();
34 setPill({ left: br.left - lr.left, width: br.width, ready: true });
35 };
36
37 useLayoutEffect(() => { measurePill(active); }, []);
38 useEffect(() => { measurePill(active); }, [active]);
39
40 useEffect(() => {
41 const ro = new ResizeObserver(() => measurePill(active));
42 if (listRef.current) ro.observe(listRef.current);
43 return () => ro.disconnect();
44 }, [active]);
45
46 const handleSelect = (key: string) => {
47 const prevIndex = tabs.findIndex((t) => t.key === active);
48 const nextIndex = tabs.findIndex((t) => t.key === key);
49 setDirection(nextIndex > prevIndex ? "right" : "left");
50 setActive(key);
51 onChange?.(key);
52 };
53
54 const onKeyDown = (e: React.KeyboardEvent) => {
55 const i = tabs.findIndex((t) => t.key === active);
56 let next = -1;
57 if (e.key === "ArrowRight") next = (i + 1) % tabs.length;
58 if (e.key === "ArrowLeft") next = (i - 1 + tabs.length) % tabs.length;
59 if (e.key === "Home") next = 0;
60 if (e.key === "End") next = tabs.length - 1;
61 if (next < 0) return;
62 e.preventDefault();
63 const key = tabs[next].key;
64 handleSelect(key);
65 btnRefs.current[key]?.focus();
66 };
67
68 const activeTab = tabs.find((t) => t.key === active);
69
70 return (
71 <div className={["flex flex-col gap-4", className].join(" ")}>
72 <div
73 ref={listRef}
74 role="tablist"
75 onKeyDown={onKeyDown}
76 className="relative flex w-full sm:inline-flex sm:w-auto sm:self-start items-center rounded-[13px] p-1"
77 style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
78 >
79 {pill.ready && (
80 <span
81 aria-hidden
82 className="absolute top-1 bottom-1 rounded-[9px] pointer-events-none"
83 style={{
84 left: pill.left,
85 width: pill.width,
86 background: "var(--face-light)",
87 boxShadow: "var(--thumb-shadow)",
88 transition: "left 300ms var(--ease-spring), width 300ms var(--ease-spring)",
89 }}
90 />
91 )}
92
93 {tabs.map((tab) => {
94 const isActive = active === tab.key;
95 return (
96 <button
97 key={tab.key}
98 ref={(el) => { btnRefs.current[tab.key] = el; }}
99 id={`${uid}-tab-${tab.key}`}
100 role="tab"
101 type="button"
102 aria-selected={isActive}
103 aria-controls={tab.content ? `${uid}-panel-${tab.key}` : undefined}
104 tabIndex={isActive ? 0 : -1}
105 onClick={() => handleSelect(tab.key)}
106 className={[
107 "relative z-10 flex-1 sm:flex-none px-3.5 py-2 rounded-[9px] text-[13px] font-medium text-center whitespace-nowrap cursor-pointer",
108 "transition-colors duration-150",
109 "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-neutral-900",
110 isActive ? "text-neutral-900" : "text-neutral-500 hover:text-neutral-800",
111 ].join(" ")}
112 >
113 {tab.label}
114 </button>
115 );
116 })}
117 </div>
118
119 {activeTab?.content && (
120 <div
121 key={active}
122 id={`${uid}-panel-${active}`}
123 role="tabpanel"
124 aria-labelledby={`${uid}-tab-${active}`}
125 tabIndex={0}
126 className={[
127 "text-[13px] text-neutral-600 leading-relaxed rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neutral-900",
128 direction === "right" ? "animate-tab-slide-right" : "animate-tab-slide-left",
129 ].join(" ")}
130 >
131 {activeTab.content}
132 </div>
133 )}
134 </div>
135 );
136}