@syscore/ui-library 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,238 +1,386 @@
1
1
  "use client";
2
2
 
3
- import React from "react";
4
- import * as TabsPrimitive from "@radix-ui/react-tabs";
5
- import { AnimatePresence, motion, Transition } from "motion/react";
6
-
7
- import { useTabs, type Tab as BaseTab } from "@/hooks/use-tabs";
3
+ import {
4
+ AnimatePresence,
5
+ animate,
6
+ motion,
7
+ Transition,
8
+ useMotionValue,
9
+ } from "motion/react";
8
10
  import { cn } from "@/lib/utils";
11
+ import {
12
+ Children,
13
+ cloneElement,
14
+ createContext,
15
+ isValidElement,
16
+ useCallback,
17
+ useContext,
18
+ useEffect,
19
+ useLayoutEffect,
20
+ useRef,
21
+ useState,
22
+ ReactNode,
23
+ ReactElement,
24
+ } from "react";
9
25
 
10
- export type Tab = BaseTab & {
11
- content?: React.ReactNode;
12
- };
26
+ export type TabVariant = "default" | "danger";
27
+
28
+ interface TabsContextValue {
29
+ selectedValue: string;
30
+ setSelectedValue: (value: string) => void;
31
+ direction: number;
32
+ registerValue: (value: string) => void;
33
+ }
34
+
35
+ const TabsContext = createContext<TabsContextValue | null>(null);
13
36
 
14
- interface AnimatedTabsProps {
15
- tabs: Tab[];
37
+ function useTabsContext() {
38
+ const ctx = useContext(TabsContext);
39
+ if (!ctx)
40
+ throw new Error("Tabs compound components must be used inside <Tabs>");
41
+ return ctx;
16
42
  }
17
43
 
18
- const transition = {
19
- type: "tween" as const,
20
- ease: "easeOut" as const,
44
+ interface TabsNavContextValue {
45
+ registerRef: (value: string, el: HTMLButtonElement | null) => void;
46
+ }
47
+
48
+ const TabsNavContext = createContext<TabsNavContextValue | null>(null);
49
+
50
+ function useTabsNavContext() {
51
+ const ctx = useContext(TabsNavContext);
52
+ if (!ctx) throw new Error("TabsTrigger must be used inside <TabsList>");
53
+ return ctx;
54
+ }
55
+
56
+ const transition: Transition = {
57
+ type: "tween",
58
+ ease: "easeOut",
21
59
  duration: 0.15,
22
60
  };
23
61
 
24
- // const getHoverAnimationProps = (hoveredRect: DOMRect, navRect: DOMRect) => ({
25
- // x: hoveredRect.left - navRect.left - 10,
26
- // y: hoveredRect.top - navRect.top - 4,
27
- // width: hoveredRect.width + 20,
28
- // height: hoveredRect.height + 10,
29
- // });
62
+ const contentVariants = {
63
+ enter: (dir: number) => ({ opacity: 0, x: dir * 20 }),
64
+ center: { opacity: 1, x: 0 },
65
+ exit: (dir: number) => ({ opacity: 0, x: dir * -20 }),
66
+ };
67
+
68
+ function AnimatedHeight({
69
+ children,
70
+ selectedValue,
71
+ }: {
72
+ children: ReactNode;
73
+ selectedValue: string;
74
+ }) {
75
+ const containerRef = useRef<HTMLDivElement>(null);
76
+ const innerRef = useRef<HTMLDivElement>(null);
77
+ const currentHeight = useRef(0);
78
+
79
+ useLayoutEffect(() => {
80
+ const container = containerRef.current;
81
+ const inner = innerRef.current;
82
+ if (!container || !inner) return;
83
+
84
+ const initialHeight = inner.offsetHeight;
85
+ if (currentHeight.current === 0) {
86
+ currentHeight.current = initialHeight;
87
+ container.style.height = `${initialHeight}px`;
88
+ return;
89
+ }
90
+
91
+ // Keep observing until the height differs from current (new content has mounted)
92
+ const ro = new ResizeObserver(() => {
93
+ const targetHeight = inner.offsetHeight;
94
+ if (targetHeight > 0 && targetHeight !== currentHeight.current) {
95
+ currentHeight.current = targetHeight;
96
+ ro.disconnect();
97
+ animate(container, { height: targetHeight }, { duration: 0.25, ease: "easeInOut" });
98
+ }
99
+ });
100
+ ro.observe(inner);
101
+ return () => ro.disconnect();
102
+ }, [selectedValue]);
30
103
 
31
- const TabContent = ({ tab }: { tab: Tab }) => {
32
104
  return (
33
- <motion.div
34
- key={tab.value}
35
- initial={{ opacity: 0, y: 10 }}
36
- animate={{ opacity: 1, y: 0 }}
37
- exit={{ opacity: 0, y: -10 }}
38
- transition={transition as Transition}
39
- >
40
- {tab.content}
41
- </motion.div>
105
+ <div ref={containerRef} className="overflow-clip">
106
+ <div ref={innerRef}>{children}</div>
107
+ </div>
42
108
  );
43
- };
109
+ }
44
110
 
45
- const Tabs = ({
46
- tabs,
47
- selectedTabIndex,
48
- setSelectedTab,
111
+ function Tabs({
112
+ children,
113
+ defaultValue,
114
+ className,
49
115
  }: {
50
- tabs: Tab[];
51
- selectedTabIndex: number;
52
- setSelectedTab: (input: [number, number]) => void;
53
- }) => {
54
- const [buttonRefs, setButtonRefs] = React.useState<
55
- Array<HTMLButtonElement | null>
56
- >([]);
116
+ children: ReactNode;
117
+ defaultValue: string;
118
+ className?: string;
119
+ }) {
120
+ const [selectedValue, setSelectedValue] = useState(defaultValue);
121
+ const [direction, setDirection] = useState(0);
57
122
 
58
- React.useEffect(() => {
59
- setButtonRefs((prev) => prev.slice(0, tabs.length));
60
- }, [tabs.length]);
123
+ // Stable ordered registry — TabsTrigger registers on mount in render order.
124
+ const valuesRef = useRef<string[]>([]);
125
+ const selectedValueRef = useRef(defaultValue);
61
126
 
62
- const navRef = React.useRef<HTMLDivElement>(null);
63
- const navRect = navRef.current?.getBoundingClientRect();
127
+ const registerValue = useCallback((value: string) => {
128
+ if (!valuesRef.current.includes(value)) {
129
+ valuesRef.current.push(value);
130
+ }
131
+ }, []);
64
132
 
65
- const selectedRect = buttonRefs[selectedTabIndex]?.getBoundingClientRect();
133
+ const handleSetSelectedValue = useCallback((value: string) => {
134
+ const currentIdx = valuesRef.current.indexOf(selectedValueRef.current);
135
+ const nextIdx = valuesRef.current.indexOf(value);
136
+ setDirection(nextIdx > currentIdx ? 1 : -1);
137
+ selectedValueRef.current = value;
138
+ setSelectedValue(value);
139
+ }, []);
66
140
 
67
- const [hoveredTabIndex, setHoveredTabIndex] = React.useState<number | null>(
68
- null,
141
+ const childArray = Children.toArray(children);
142
+ const contentChildren = childArray.filter(
143
+ (child): child is ReactElement<{ value: string }> =>
144
+ isValidElement(child) && child.type === TabsContent,
145
+ );
146
+ const nonContentChildren = childArray.filter(
147
+ (child) => !(isValidElement(child) && child.type === TabsContent),
148
+ );
149
+ const activeContent = contentChildren.find(
150
+ (child) => child.props.value === selectedValue,
69
151
  );
70
- const hoveredRect =
71
- buttonRefs[hoveredTabIndex ?? -1]?.getBoundingClientRect();
72
152
 
73
153
  return (
74
- <nav
75
- ref={navRef}
76
- className="tabs-nav"
77
- onPointerLeave={() => setHoveredTabIndex(null)}
154
+ <TabsContext.Provider
155
+ value={{ selectedValue, setSelectedValue: handleSetSelectedValue, direction, registerValue }}
78
156
  >
79
- {tabs.map((item, i) => {
80
- const isActive = selectedTabIndex === i;
81
-
82
- return (
83
- <button
84
- key={item.value}
85
- className="tabs-nav-button"
86
- onPointerEnter={() => setHoveredTabIndex(i)}
87
- onFocus={() => setHoveredTabIndex(i)}
88
- onClick={() => setSelectedTab([i, i > selectedTabIndex ? 1 : -1])}
89
- >
90
- <motion.span
91
- ref={(el) => {
92
- buttonRefs[i] = el as HTMLButtonElement;
93
- }}
94
- className={cn("tabs-nav-button-text", {
95
- "tabs-nav-button-text--inactive": !isActive,
96
- "tabs-nav-button-text--active": isActive,
97
- })}
98
- >
99
- <small
100
- className={item.value === "danger-zone" ? "tabs-nav-button-text--danger" : ""}
101
- >
102
- {item.label}
103
- </small>
104
- </motion.span>
105
- </button>
106
- );
107
- })}
108
-
109
- {/* <AnimatePresence>
110
- {hoveredRect && navRect && (
111
- <motion.div
112
- key="hover"
113
- className={`absolute z-10 top-0 left-0 rounded-md ${
114
- hoveredTabIndex ===
115
- tabs.findIndex(({ value }) => value === "danger-zone")
116
- ? "bg-red-500/50"
117
- : "bg-gray-100/50"
118
- }`}
119
- initial={{
120
- ...getHoverAnimationProps(hoveredRect, navRect),
121
- opacity: 0,
122
- }}
123
- animate={{
124
- ...getHoverAnimationProps(hoveredRect, navRect),
125
- opacity: 1,
126
- }}
127
- exit={{
128
- ...getHoverAnimationProps(hoveredRect, navRect),
129
- opacity: 0,
130
- }}
131
- transition={transition as Transition}
132
- />
133
- )}
134
- </AnimatePresence> */}
135
-
136
- <AnimatePresence>
137
- {selectedRect && navRect && (
138
- <motion.div
139
- className={cn("tabs-nav-indicator", {
140
- "tabs-nav-indicator--danger": selectedTabIndex ===
141
- tabs.findIndex(({ value }) => value === "danger-zone"),
142
- "tabs-nav-indicator--default": selectedTabIndex !==
143
- tabs.findIndex(({ value }) => value === "danger-zone"),
144
- })}
145
- initial={false}
146
- animate={{
147
- width: selectedRect.width,
148
- x: selectedRect.left - navRect.left,
149
- opacity: 1,
150
- }}
151
- transition={transition as Transition}
152
- />
153
- )}
154
- </AnimatePresence>
155
-
156
- <div className="tabs-nav-underline" />
157
- </nav>
157
+ <div className={cn("tabs-container", className)}>
158
+ {nonContentChildren}
159
+ <AnimatedHeight selectedValue={selectedValue}>
160
+ <AnimatePresence mode="wait" custom={direction}>
161
+ {activeContent &&
162
+ cloneElement(activeContent, { key: activeContent.props.value })}
163
+ </AnimatePresence>
164
+ </AnimatedHeight>
165
+ </div>
166
+ </TabsContext.Provider>
158
167
  );
159
- };
168
+ }
169
+
170
+ function TabsList({
171
+ children,
172
+ className,
173
+ containerClassName,
174
+ fullWidth = false,
175
+ }: {
176
+ children: ReactNode;
177
+ className?: string;
178
+ containerClassName?: string;
179
+ fullWidth?: boolean;
180
+ }) {
181
+ const { selectedValue } = useTabsContext();
182
+ const navRef = useRef<HTMLDivElement>(null);
183
+ const containerRef = useRef<HTMLDivElement>(null);
184
+ const buttonRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());
185
+
186
+ // Motion values for the indicator — bypasses React state so updates are
187
+ // applied directly to the DOM without re-renders.
188
+ const indicatorX = useMotionValue(-999);
189
+ const indicatorWidth = useMotionValue(0);
190
+ // Only isDanger requires React state (drives a CSS class change).
191
+ const [indicatorColor, setIndicatorColor] = useState<
192
+ "default" | "danger" | null
193
+ >(null);
194
+
195
+ // On initial mount, snap the indicator without animation.
196
+ const isFirstRender = useRef(true);
197
+
198
+ const selectedValueRef = useRef(selectedValue);
199
+ selectedValueRef.current = selectedValue;
200
+
201
+ const updateIndicatorPosition = useCallback(() => {
202
+ const nav = navRef.current;
203
+ const selectedEl = buttonRefs.current.get(selectedValueRef.current);
204
+ if (!nav || !selectedEl) return;
205
+ const navRect = nav.getBoundingClientRect();
206
+ const selectedRect = selectedEl.getBoundingClientRect();
207
+ indicatorX.set(selectedRect.left - navRect.left);
208
+ indicatorWidth.set(selectedRect.width);
209
+ }, [indicatorX, indicatorWidth]);
160
210
 
161
- export function AnimatedTabs({ tabs }: AnimatedTabsProps) {
162
- const [hookProps] = React.useState(() => {
163
- const initialTabId =
164
- tabs.find((tab) => tab.value === "home")?.value || tabs[0].value;
211
+ useEffect(() => {
212
+ const el = containerRef.current;
213
+ if (!el) return;
165
214
 
166
- return {
167
- tabs: tabs.map((tab) => ({
168
- ...tab,
169
- })),
170
- initialTabId,
215
+ const updateScroll = () => {
216
+ const canLeft = el.scrollLeft > 0;
217
+ const canRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
218
+ el.dataset.scrollLeft = canLeft ? "true" : "";
219
+ el.dataset.scrollRight = canRight ? "true" : "";
171
220
  };
172
- });
173
221
 
174
- const framer = useTabs(hookProps);
222
+ updateScroll();
223
+ el.addEventListener("scroll", updateScroll, { passive: true });
224
+ const ro = new ResizeObserver(() => {
225
+ updateScroll();
226
+ updateIndicatorPosition();
227
+ });
228
+ ro.observe(el);
229
+ return () => {
230
+ el.removeEventListener("scroll", updateScroll);
231
+ ro.disconnect();
232
+ };
233
+ }, [updateIndicatorPosition]);
234
+
235
+ const registerRef = useCallback(
236
+ (value: string, el: HTMLButtonElement | null) => {
237
+ buttonRefs.current.set(value, el);
238
+ },
239
+ [],
240
+ );
241
+
242
+ useLayoutEffect(() => {
243
+ const nav = navRef.current;
244
+ const container = containerRef.current;
245
+ const selectedEl = buttonRefs.current.get(selectedValue);
246
+ if (!nav || !container || !selectedEl) return;
247
+
248
+ const navRect = nav.getBoundingClientRect();
249
+ const selectedRect = selectedEl.getBoundingClientRect();
250
+ const P = selectedRect.left - navRect.left;
251
+ const W = selectedRect.width;
252
+ const danger = selectedEl.dataset?.variant === "danger";
253
+
254
+ setIndicatorColor(danger ? "danger" : "default");
255
+
256
+ if (isFirstRender.current) {
257
+ isFirstRender.current = false;
258
+ indicatorX.set(P);
259
+ indicatorWidth.set(W);
260
+ return;
261
+ }
262
+
263
+ const target = P - container.clientWidth / 2 + W / 2;
264
+ const clampedTarget = Math.max(
265
+ 0,
266
+ Math.min(target, container.scrollWidth - container.clientWidth),
267
+ );
268
+ const needsScroll = Math.abs(clampedTarget - container.scrollLeft) > 1;
269
+
270
+ if (needsScroll) {
271
+ indicatorX.set(P);
272
+ indicatorWidth.set(W);
273
+ container.scrollTo({ left: clampedTarget, behavior: "smooth" });
274
+ } else {
275
+ animate(indicatorX, P, transition);
276
+ animate(indicatorWidth, W, transition);
277
+ }
278
+ }, [selectedValue, indicatorX, indicatorWidth]);
175
279
 
176
280
  return (
177
- <div className="tabs-container">
178
- <div className="tabs-container-inner">
179
- <Tabs {...framer.tabProps} />
281
+ <TabsNavContext.Provider value={{ registerRef }}>
282
+ <div
283
+ ref={containerRef}
284
+ className={cn("tabs-container-inner", containerClassName)}
285
+ >
286
+ <nav
287
+ ref={navRef}
288
+ className={cn(
289
+ "tabs-nav",
290
+ { "tabs-nav--full-width": fullWidth },
291
+ className,
292
+ )}
293
+ >
294
+ {children}
295
+
296
+ {indicatorColor !== null && (
297
+ <motion.div
298
+ className={cn("tabs-nav-indicator", {
299
+ "tabs-nav-indicator--danger": indicatorColor === "danger",
300
+ "tabs-nav-indicator--default": indicatorColor === "default",
301
+ })}
302
+ style={{ x: indicatorX, width: indicatorWidth }}
303
+ />
304
+ )}
305
+
306
+ <div className="tabs-nav-underline" />
307
+ </nav>
180
308
  </div>
181
- <AnimatePresence mode="wait">
182
- <TabContent tab={framer.selectedTab} />
183
- </AnimatePresence>
184
- </div>
309
+ </TabsNavContext.Provider>
310
+ );
311
+ }
312
+
313
+ function TabsTrigger({
314
+ value,
315
+ children,
316
+ className,
317
+ variant = "default",
318
+ }: {
319
+ value: string;
320
+ children: ReactNode;
321
+ className?: string;
322
+ variant?: TabVariant;
323
+ }) {
324
+ const { selectedValue, setSelectedValue, registerValue } = useTabsContext();
325
+ const { registerRef } = useTabsNavContext();
326
+
327
+ useLayoutEffect(() => {
328
+ registerValue(value);
329
+ }, [registerValue, value]);
330
+ const isActive = selectedValue === value;
331
+
332
+ const refCallback = useCallback(
333
+ (el: HTMLButtonElement | null) => registerRef(value, el),
334
+ [registerRef, value],
335
+ );
336
+
337
+ const handleClick = useCallback(
338
+ () => setSelectedValue(value),
339
+ [setSelectedValue, value],
340
+ );
341
+
342
+ return (
343
+ <button
344
+ className={cn("tabs-nav-button", className)}
345
+ data-variant={variant}
346
+ onClick={handleClick}
347
+ ref={refCallback}
348
+ >
349
+ <span
350
+ className={cn("tabs-nav-button-text overline-large", {
351
+ "tabs-nav-button-text--inactive": !isActive,
352
+ "tabs-nav-button-text--active": isActive,
353
+ })}
354
+ >
355
+ {children}
356
+ </span>
357
+ </button>
358
+ );
359
+ }
360
+
361
+ function TabsContent({
362
+ children,
363
+ className,
364
+ }: {
365
+ value: string;
366
+ children: ReactNode;
367
+ className?: string;
368
+ }) {
369
+ const { direction } = useTabsContext();
370
+
371
+ return (
372
+ <motion.div
373
+ className={className}
374
+ custom={direction}
375
+ variants={contentVariants}
376
+ initial="enter"
377
+ animate="center"
378
+ exit="exit"
379
+ transition={transition}
380
+ >
381
+ {children}
382
+ </motion.div>
185
383
  );
186
384
  }
187
385
 
188
- // Standard Radix UI Tabs Components
189
- const TabsRoot = React.forwardRef<
190
- React.ElementRef<typeof TabsPrimitive.Root>,
191
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
192
- >(({ className, ...props }, ref) => (
193
- <TabsPrimitive.Root
194
- ref={ref}
195
- className={cn("tabs", className)}
196
- {...props}
197
- />
198
- ));
199
- TabsRoot.displayName = TabsPrimitive.Root.displayName;
200
-
201
- const TabsList = React.forwardRef<
202
- React.ElementRef<typeof TabsPrimitive.List>,
203
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
204
- >(({ className, ...props }, ref) => (
205
- <TabsPrimitive.List
206
- ref={ref}
207
- className={cn("tabs-list", className)}
208
- {...props}
209
- />
210
- ));
211
- TabsList.displayName = TabsPrimitive.List.displayName;
212
-
213
- const TabsTrigger = React.forwardRef<
214
- React.ElementRef<typeof TabsPrimitive.Trigger>,
215
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
216
- >(({ className, ...props }, ref) => (
217
- <TabsPrimitive.Trigger
218
- ref={ref}
219
- className={cn("tabs-trigger", className)}
220
- {...props}
221
- />
222
- ));
223
- TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
224
-
225
- const TabsContent = React.forwardRef<
226
- React.ElementRef<typeof TabsPrimitive.Content>,
227
- React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
228
- >(({ className, ...props }, ref) => (
229
- <TabsPrimitive.Content
230
- ref={ref}
231
- className={cn("tabs-content", className)}
232
- {...props}
233
- />
234
- ));
235
- TabsContent.displayName = TabsPrimitive.Content.displayName;
236
-
237
- // Export standard Radix UI tabs components
238
- export { TabsRoot as Tabs, TabsList, TabsTrigger, TabsContent };
386
+ export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -228,7 +228,7 @@ function TooltipContent({
228
228
  </div>
229
229
  )}
230
230
  {variant !== "simple" && !hideClose && (
231
- <ToggleClose className="absolute top-4 right-4" />
231
+ <ToggleClose />
232
232
  )}
233
233
  {children}
234
234
  </TooltipPrimitive.Content>
@@ -243,7 +243,7 @@ function ToggleClose({ className }: { className?: string }) {
243
243
  <button
244
244
  data-slot="tooltip-close"
245
245
  onClick={close}
246
- className={cn("group cursor-pointer", className)}
246
+ className={className}
247
247
  >
248
248
  <UtilityClose />
249
249
  </button>