-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSlidingTabs.jsx
More file actions
232 lines (220 loc) · 7.68 KB
/
SlidingTabs.jsx
File metadata and controls
232 lines (220 loc) · 7.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { LayoutGrid, LayoutList, List, Package } from "lucide-react";
import { cn } from "../utils/cn";
/**
* SlidingTabs: Reusable measured sliding-pill tabs that work for any number of items.
* - items: [{ key, label, icon, onClick? }] or route-bound where parent handles navigation
* - activeKey: the currently active item's key
* - onSelect: optional callback with (key, index)
* - className/buttonClassName: optional extra classes
*
* Behavior:
* - Measures offsetLeft/offsetWidth of active button
* - Positions pill without animation on first paint (avoids jump on refresh)
* - Animates subsequent moves
*/
export const SlidingTabs = ({
items = [],
activeKey,
onSelect,
className = "",
buttonClassName = "",
pillClassName = "",
}) => {
const containerRef = useRef(null);
const slidingBgRef = useRef(null);
const buttonsRef = useRef([]);
const didInitRef = useRef(false);
const [isMobile, setIsMobile] = useState(false);
const activeIndex = useMemo(
() =>
Math.max(
0,
items.findIndex((it) => it.key === activeKey),
),
[items, activeKey],
);
// Why: sliding pill can misalign on crowded, small viewports
// Rule: on mobile with >2 items, disable pill and style the active button instead
const isSlidingEnabled = useMemo(
() => !(isMobile && items.length > 2),
[isMobile, items.length],
);
// When pill is disabled, allow horizontal scroll for overflowed tabs
const isScrollable = useMemo(
() => isMobile && items.length > 2,
[isMobile, items.length],
);
// Detect mobile once and keep it in state (updates on resize/media change)
useEffect(() => {
try {
const mq = window.matchMedia("(max-width: 640px)");
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
} catch {
// Fallback if matchMedia is unavailable
const handleResize = () => setIsMobile(window.innerWidth <= 640);
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}
}, []);
// Places/measures the pill under the active button
const placePill = (animate) => {
const pill = slidingBgRef.current;
const activeButton = buttonsRef.current[activeIndex];
if (!pill || !activeButton) return;
const { offsetLeft, offsetWidth } = activeButton;
if (!animate) {
const prevTransition = pill.style.transition;
pill.style.transition = "none";
pill.style.transform = `translateX(${offsetLeft}px)`;
pill.style.width = `${offsetWidth}px`;
// force reflow
pill.offsetHeight;
pill.style.transition =
"transform 300ms cubic-bezier(0.4, 0, 0.2, 1), width 300ms cubic-bezier(0.4, 0, 0.2, 1)";
return;
}
pill.style.transform = `translateX(${offsetLeft}px)`;
pill.style.width = `${offsetWidth}px`;
};
// Initial placement (no animation) then animate on subsequent updates
useLayoutEffect(() => {
if (!isSlidingEnabled) return;
if (!didInitRef.current) {
placePill(false);
didInitRef.current = true;
} else {
placePill(true);
}
}, [activeIndex, items.length, isSlidingEnabled]);
return (
<div
ref={containerRef}
className={cn(
"relative flex gap-1 rounded-full border border-gray-700 bg-slate-900/70 p-1 backdrop-blur-sm",
isScrollable
? "flex-nowrap justify-start overflow-x-auto whitespace-nowrap"
: "justify-center",
className,
)}
>
{/* Sliding pill: only when enabled (desktop or <=2 items) */}
{isSlidingEnabled && (
<span
ref={slidingBgRef}
className={cn(
"absolute top-1 bottom-1 rounded-full bg-white",
pillClassName,
)}
style={{ left: 0, width: 0, transform: "translateX(0px)" }}
/>
)}
{items.map((item, index) => {
const Icon = item.icon;
const isActive = item.key === activeKey;
return (
<button
key={item.key ?? index}
ref={(el) => (buttonsRef.current[index] = el)}
onClick={() => {
item.onClick?.();
onSelect?.(item.key, index);
}}
className={cn(
"relative z-10 inline-flex shrink-0 items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors duration-300",
// Active style: pill paints the background; when pill is off, paint the button
isActive
? isSlidingEnabled
? "text-black"
: "bg-white text-black"
: "text-gray-300 hover:bg-gray-700/40 hover:text-white",
buttonClassName,
)}
>
{Icon ? <Icon className="h-4 w-4" /> : null}
{item.label}
</button>
);
})}
</div>
);
};
export default function SlidingTabsDemo() {
const items = useMemo(
() => [
{ key: "grid", label: "Grid", icon: LayoutGrid },
{ key: "list", label: "Detailed", icon: LayoutList },
{ key: "simple", label: "Simple", icon: List },
{ key: "components", label: "Components", icon: Package },
],
[],
);
const [active, setActive] = useState(items[0]?.key ?? "grid");
return (
<>
<h1 className="mb-4 text-2xl font-bold">Sliding Tabs</h1>
<div className="space-y-4">
<SlidingTabs
items={items}
activeKey={active}
onSelect={(key) => setActive(key)}
className="bg-slate-800"
/>
<div className="text-sm text-slate-600 dark:text-slate-300">
Active:{" "}
<span className="font-semibold">
{items.find((item) => item.key === active)?.label}
</span>
</div>
{active === "grid" && (
<div className="grid grid-cols-2 gap-3">
{[1, 2, 3, 4].map((n) => (
<div
key={n}
className="h-20 rounded-lg border border-slate-200 bg-slate-50 p-3 text-slate-700 shadow-xs"
>
Grid card {n}
</div>
))}
</div>
)}
{active === "list" && (
<ul className="divide-y divide-slate-200 overflow-hidden rounded-lg border border-slate-200">
{["Alpha", "Beta", "Gamma", "Delta"].map((label) => (
<li key={label} className="p-3">
<div className="flex items-center justify-between">
<span className="font-medium text-slate-700">{label}</span>
<span className="text-xs text-slate-500">details</span>
</div>
</li>
))}
</ul>
)}
{active === "simple" && (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-slate-700">
This is a minimal view. Put any simple summary here.
</div>
)}
{active === "components" && (
<div className="space-y-3 rounded-lg border border-slate-200 bg-slate-50 p-4">
<div className="flex gap-2">
<button className="rounded-md bg-slate-900 px-3 py-1 text-sm text-white">
Button A
</button>
<button className="rounded-md bg-slate-900/80 px-3 py-1 text-sm text-white">
Button B
</button>
</div>
<p className="text-sm text-slate-600">
Showcase UI components related content here.
</p>
</div>
)}
</div>
</>
);
}