@entro314labs/react-arc-tabs 1.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,990 @@
1
+ // src/ArcTabs.tsx
2
+ import * as React from "react";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ var joinClassNames = (...parts) => parts.filter(Boolean).join(" ");
5
+ var toCssSize = (value) => typeof value === "number" ? `${value}px` : value;
6
+ var findFirstEnabledIndex = (items) => items.findIndex((item) => !item.disabled);
7
+ var getEnabledIndices = (items) => items.reduce((acc, item, index) => {
8
+ if (!item.disabled) acc.push(index);
9
+ return acc;
10
+ }, []);
11
+ var getNextEnabledIndex = (enabledIndices, currentIndex, direction) => {
12
+ if (!enabledIndices.length) return -1;
13
+ const currentPosition = enabledIndices.indexOf(currentIndex);
14
+ if (currentPosition === -1) {
15
+ return direction === 1 ? enabledIndices[0] ?? -1 : enabledIndices[enabledIndices.length - 1] ?? -1;
16
+ }
17
+ const nextPosition = (currentPosition + direction + enabledIndices.length) % enabledIndices.length;
18
+ return enabledIndices[nextPosition] ?? -1;
19
+ };
20
+ function ArcTabs({
21
+ items,
22
+ value,
23
+ defaultValue,
24
+ onValueChange,
25
+ activationMode = "automatic",
26
+ keepMounted = true,
27
+ size = "md",
28
+ fit = "content",
29
+ motionPreset = "subtle",
30
+ motionDuration = 260,
31
+ ariaLabel = "Tabs",
32
+ listId,
33
+ tabsClassName,
34
+ panelClassName,
35
+ radius,
36
+ gap,
37
+ panelPadding,
38
+ accentColor,
39
+ tabBackground,
40
+ tabHoverBackground,
41
+ panelBackground,
42
+ panelBorderColor,
43
+ emptyState = null,
44
+ renderTabLabel,
45
+ renderPanel,
46
+ className,
47
+ style,
48
+ ...rest
49
+ }) {
50
+ const reactId = React.useId();
51
+ const baseId = React.useMemo(
52
+ () => (listId ?? `arc-tabs-${reactId}`).replace(/:/g, ""),
53
+ [listId, reactId]
54
+ );
55
+ const isControlled = value !== void 0;
56
+ const firstEnabledIndex = React.useMemo(
57
+ () => findFirstEnabledIndex(items),
58
+ [items]
59
+ );
60
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(() => {
61
+ const requested = defaultValue;
62
+ const requestedMatch = items.find(
63
+ (item) => !item.disabled && item.id === requested
64
+ );
65
+ if (requestedMatch) return requestedMatch.id;
66
+ return firstEnabledIndex >= 0 ? items[firstEnabledIndex]?.id : void 0;
67
+ });
68
+ const rawValue = isControlled ? value : uncontrolledValue;
69
+ const strictSelectedIndex = React.useMemo(
70
+ () => items.findIndex((item) => !item.disabled && item.id === rawValue),
71
+ [items, rawValue]
72
+ );
73
+ const selectedIndex = strictSelectedIndex >= 0 ? strictSelectedIndex : firstEnabledIndex;
74
+ const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : void 0;
75
+ React.useEffect(() => {
76
+ if (isControlled) return;
77
+ if (strictSelectedIndex !== -1) return;
78
+ if (firstEnabledIndex !== -1) {
79
+ const fallbackId = items[firstEnabledIndex]?.id;
80
+ setUncontrolledValue(fallbackId);
81
+ } else {
82
+ setUncontrolledValue(void 0);
83
+ }
84
+ }, [isControlled, strictSelectedIndex, firstEnabledIndex, items]);
85
+ const [focusedIndex, setFocusedIndex] = React.useState(selectedIndex);
86
+ React.useEffect(() => {
87
+ if (selectedIndex === -1) {
88
+ setFocusedIndex(-1);
89
+ return;
90
+ }
91
+ if (focusedIndex < 0 || focusedIndex >= items.length || items[focusedIndex]?.disabled) {
92
+ setFocusedIndex(selectedIndex);
93
+ }
94
+ }, [focusedIndex, selectedIndex, items]);
95
+ const enabledIndices = React.useMemo(() => getEnabledIndices(items), [items]);
96
+ const tabRefs = React.useRef([]);
97
+ const listRef = React.useRef(null);
98
+ const activePanelRef = React.useRef(null);
99
+ const hasMountedRef = React.useRef(false);
100
+ const previousSelectedIndexRef = React.useRef(selectedIndex);
101
+ const [hasInteracted, setHasInteracted] = React.useState(false);
102
+ const [panelDirection, setPanelDirection] = React.useState("none");
103
+ const [indicator, setIndicator] = React.useState({
104
+ x: 0,
105
+ width: 0,
106
+ ready: false
107
+ });
108
+ const effectiveMotionDuration = motionPreset === "none" ? 0 : Math.max(0, motionDuration);
109
+ const showSlidingIndicator = motionPreset === "expressive" && selectedIndex >= 0;
110
+ React.useEffect(() => {
111
+ tabRefs.current = tabRefs.current.slice(0, items.length);
112
+ }, [items.length]);
113
+ React.useEffect(() => {
114
+ const previous = previousSelectedIndexRef.current;
115
+ if (!hasMountedRef.current) {
116
+ hasMountedRef.current = true;
117
+ previousSelectedIndexRef.current = selectedIndex;
118
+ return;
119
+ }
120
+ if (previous !== selectedIndex) {
121
+ setHasInteracted(true);
122
+ if (selectedIndex >= 0 && previous >= 0) {
123
+ setPanelDirection(selectedIndex > previous ? "forward" : "backward");
124
+ }
125
+ }
126
+ previousSelectedIndexRef.current = selectedIndex;
127
+ }, [selectedIndex]);
128
+ const focusTabIndex = React.useCallback((index) => {
129
+ if (index < 0) return;
130
+ setFocusedIndex(index);
131
+ tabRefs.current[index]?.focus();
132
+ }, []);
133
+ const selectTab = React.useCallback(
134
+ (index) => {
135
+ const item = items[index];
136
+ if (!item || item.disabled) return;
137
+ if (index === selectedIndex) {
138
+ setFocusedIndex(index);
139
+ return;
140
+ }
141
+ setHasInteracted(true);
142
+ if (selectedIndex >= 0) {
143
+ setPanelDirection(index > selectedIndex ? "forward" : "backward");
144
+ }
145
+ if (!isControlled) {
146
+ setUncontrolledValue(item.id);
147
+ }
148
+ setFocusedIndex(index);
149
+ onValueChange?.(item.id, item, index);
150
+ },
151
+ [items, selectedIndex, isControlled, onValueChange]
152
+ );
153
+ const syncIndicator = React.useCallback(() => {
154
+ if (!showSlidingIndicator) {
155
+ setIndicator(
156
+ (previous) => previous.ready || previous.width !== 0 || previous.x !== 0 ? { x: 0, width: 0, ready: false } : previous
157
+ );
158
+ return;
159
+ }
160
+ const listElement = listRef.current;
161
+ const selectedTab = selectedIndex >= 0 ? tabRefs.current[selectedIndex] ?? null : null;
162
+ if (!listElement || !selectedTab) return;
163
+ const listRect = listElement.getBoundingClientRect();
164
+ const tabRect = selectedTab.getBoundingClientRect();
165
+ const nextX = tabRect.left - listRect.left + listElement.scrollLeft;
166
+ const nextWidth = tabRect.width;
167
+ setIndicator((previous) => {
168
+ const changedX = Math.abs(previous.x - nextX) > 0.5;
169
+ const changedWidth = Math.abs(previous.width - nextWidth) > 0.5;
170
+ if (!changedX && !changedWidth && previous.ready) {
171
+ return previous;
172
+ }
173
+ return {
174
+ x: nextX,
175
+ width: nextWidth,
176
+ ready: true
177
+ };
178
+ });
179
+ }, [selectedIndex, showSlidingIndicator]);
180
+ React.useEffect(() => {
181
+ syncIndicator();
182
+ }, [syncIndicator, items.length, size, fit]);
183
+ React.useEffect(() => {
184
+ if (!showSlidingIndicator) return;
185
+ const listElement = listRef.current;
186
+ if (!listElement) return;
187
+ const onResize = () => syncIndicator();
188
+ const onScroll = () => syncIndicator();
189
+ const frame = requestAnimationFrame(syncIndicator);
190
+ let observer = null;
191
+ if (typeof ResizeObserver !== "undefined") {
192
+ observer = new ResizeObserver(() => syncIndicator());
193
+ observer.observe(listElement);
194
+ tabRefs.current.forEach((tabElement) => {
195
+ if (tabElement) observer?.observe(tabElement);
196
+ });
197
+ }
198
+ listElement.addEventListener("scroll", onScroll, { passive: true });
199
+ window.addEventListener("resize", onResize);
200
+ return () => {
201
+ cancelAnimationFrame(frame);
202
+ listElement.removeEventListener("scroll", onScroll);
203
+ window.removeEventListener("resize", onResize);
204
+ observer?.disconnect();
205
+ };
206
+ }, [showSlidingIndicator, syncIndicator, items.length]);
207
+ React.useEffect(() => {
208
+ if (!hasInteracted || motionPreset === "none" || effectiveMotionDuration <= 0) {
209
+ return;
210
+ }
211
+ const panelElement = activePanelRef.current;
212
+ if (!panelElement || typeof panelElement.animate !== "function") {
213
+ return;
214
+ }
215
+ if (typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
216
+ return;
217
+ }
218
+ const offsetX = motionPreset === "expressive" ? panelDirection === "forward" ? 20 : panelDirection === "backward" ? -20 : 0 : 0;
219
+ const offsetY = motionPreset === "expressive" ? 10 : 6;
220
+ const startScale = motionPreset === "expressive" ? 0.985 : 0.995;
221
+ const animation = panelElement.animate(
222
+ [
223
+ {
224
+ opacity: 0,
225
+ transform: `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${startScale})`,
226
+ filter: "blur(1px)"
227
+ },
228
+ {
229
+ opacity: 1,
230
+ transform: "translate3d(0, 0, 0) scale(1)",
231
+ filter: "blur(0px)"
232
+ }
233
+ ],
234
+ {
235
+ duration: effectiveMotionDuration,
236
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)",
237
+ fill: "both"
238
+ }
239
+ );
240
+ return () => {
241
+ animation.cancel();
242
+ };
243
+ }, [
244
+ selectedIndex,
245
+ hasInteracted,
246
+ motionPreset,
247
+ panelDirection,
248
+ effectiveMotionDuration
249
+ ]);
250
+ const handleTabKeyDown = React.useCallback(
251
+ (event, index) => {
252
+ if (!enabledIndices.length) return;
253
+ switch (event.key) {
254
+ case "ArrowRight": {
255
+ event.preventDefault();
256
+ const next = getNextEnabledIndex(enabledIndices, index, 1);
257
+ if (next !== -1) {
258
+ focusTabIndex(next);
259
+ if (activationMode === "automatic") selectTab(next);
260
+ }
261
+ break;
262
+ }
263
+ case "ArrowLeft": {
264
+ event.preventDefault();
265
+ const previous = getNextEnabledIndex(enabledIndices, index, -1);
266
+ if (previous !== -1) {
267
+ focusTabIndex(previous);
268
+ if (activationMode === "automatic") selectTab(previous);
269
+ }
270
+ break;
271
+ }
272
+ case "Home": {
273
+ event.preventDefault();
274
+ const first = enabledIndices[0];
275
+ if (first !== void 0) {
276
+ focusTabIndex(first);
277
+ if (activationMode === "automatic") selectTab(first);
278
+ }
279
+ break;
280
+ }
281
+ case "End": {
282
+ event.preventDefault();
283
+ const last = enabledIndices[enabledIndices.length - 1];
284
+ if (last !== void 0) {
285
+ focusTabIndex(last);
286
+ if (activationMode === "automatic") selectTab(last);
287
+ }
288
+ break;
289
+ }
290
+ case "Enter":
291
+ case " ":
292
+ case "Spacebar": {
293
+ if (activationMode === "manual") {
294
+ event.preventDefault();
295
+ selectTab(index);
296
+ }
297
+ break;
298
+ }
299
+ default:
300
+ break;
301
+ }
302
+ },
303
+ [activationMode, enabledIndices, focusTabIndex, selectTab]
304
+ );
305
+ const themedStyle = React.useMemo(() => {
306
+ const cssVars = { ...style };
307
+ if (radius !== void 0) {
308
+ cssVars["--arc-radius"] = `${radius}px`;
309
+ }
310
+ if (gap !== void 0) {
311
+ cssVars["--arc-gap"] = `${gap}px`;
312
+ }
313
+ const panelPaddingValue = toCssSize(panelPadding);
314
+ if (panelPaddingValue !== void 0) {
315
+ cssVars["--arc-panel-padding"] = panelPaddingValue;
316
+ }
317
+ if (accentColor) {
318
+ cssVars["--arc-accent"] = accentColor;
319
+ }
320
+ if (tabBackground) {
321
+ cssVars["--arc-tab-bg"] = tabBackground;
322
+ }
323
+ if (tabHoverBackground) {
324
+ cssVars["--arc-tab-hover-bg"] = tabHoverBackground;
325
+ }
326
+ if (panelBackground) {
327
+ cssVars["--arc-panel-bg"] = panelBackground;
328
+ }
329
+ if (panelBorderColor) {
330
+ cssVars["--arc-panel-border"] = panelBorderColor;
331
+ }
332
+ cssVars["--arc-motion-duration"] = `${effectiveMotionDuration}ms`;
333
+ return cssVars;
334
+ }, [
335
+ style,
336
+ radius,
337
+ gap,
338
+ panelPadding,
339
+ accentColor,
340
+ tabBackground,
341
+ tabHoverBackground,
342
+ panelBackground,
343
+ panelBorderColor,
344
+ effectiveMotionDuration
345
+ ]);
346
+ const rootClassName = joinClassNames(
347
+ "arc-tabs",
348
+ `arc-tabs--size-${size}`,
349
+ `arc-tabs--fit-${fit}`,
350
+ `arc-tabs--motion-${motionPreset}`,
351
+ hasInteracted && "arc-tabs--has-interacted",
352
+ panelDirection !== "none" && `arc-tabs--direction-${panelDirection}`,
353
+ className
354
+ );
355
+ const indicatorStyle = React.useMemo(
356
+ () => ({
357
+ "--arc-indicator-x": `${indicator.x}px`,
358
+ "--arc-indicator-w": `${indicator.width}px`
359
+ }),
360
+ [indicator.x, indicator.width]
361
+ );
362
+ const renderDefaultLabel = (item) => /* @__PURE__ */ jsxs(Fragment, { children: [
363
+ item.icon ? /* @__PURE__ */ jsx("span", { className: "arc-tabs__icon", children: item.icon }) : null,
364
+ /* @__PURE__ */ jsx("span", { className: "arc-tabs__text", children: item.label }),
365
+ item.badge !== void 0 ? /* @__PURE__ */ jsx("span", { className: "arc-tabs__badge", children: item.badge }) : null
366
+ ] });
367
+ const renderPanelContent = (item, state) => renderPanel ? renderPanel(item, state) : item.content;
368
+ return /* @__PURE__ */ jsxs("div", { className: rootClassName, style: themedStyle, ...rest, children: [
369
+ /* @__PURE__ */ jsxs(
370
+ "ul",
371
+ {
372
+ ref: listRef,
373
+ className: joinClassNames("arc-tabs__list", tabsClassName),
374
+ role: "tablist",
375
+ "aria-label": ariaLabel,
376
+ id: `${baseId}-list`,
377
+ children: [
378
+ showSlidingIndicator ? /* @__PURE__ */ jsx(
379
+ "li",
380
+ {
381
+ "aria-hidden": "true",
382
+ role: "presentation",
383
+ className: joinClassNames(
384
+ "arc-tabs__active-indicator",
385
+ indicator.ready && "is-ready"
386
+ ),
387
+ style: indicatorStyle
388
+ }
389
+ ) : null,
390
+ items.map((item, index) => {
391
+ const selected = index === selectedIndex;
392
+ const disabled = Boolean(item.disabled);
393
+ const tabId = `${baseId}-tab-${index}`;
394
+ const panelId = `${baseId}-panel-${index}`;
395
+ const state = { index, selected, disabled };
396
+ const tabIndexValue = disabled ? -1 : focusedIndex === index || focusedIndex === -1 && selected ? 0 : -1;
397
+ return /* @__PURE__ */ jsx("li", { className: "arc-tabs__item", role: "presentation", children: /* @__PURE__ */ jsx(
398
+ "button",
399
+ {
400
+ id: tabId,
401
+ ref: (node) => {
402
+ tabRefs.current[index] = node;
403
+ },
404
+ type: "button",
405
+ role: "tab",
406
+ "aria-selected": selected,
407
+ "aria-controls": panelId,
408
+ tabIndex: tabIndexValue,
409
+ disabled,
410
+ className: "arc-tabs__tab",
411
+ onFocus: () => setFocusedIndex(index),
412
+ onClick: () => selectTab(index),
413
+ onKeyDown: (event) => handleTabKeyDown(event, index),
414
+ children: renderTabLabel ? renderTabLabel(item, state) : renderDefaultLabel(item)
415
+ }
416
+ ) }, item.id);
417
+ })
418
+ ]
419
+ }
420
+ ),
421
+ /* @__PURE__ */ jsxs("div", { className: joinClassNames("arc-tabs__panels", panelClassName), children: [
422
+ items.length === 0 && emptyState,
423
+ items.length > 0 && selectedItem === void 0 && emptyState,
424
+ items.length > 0 && selectedItem !== void 0 && keepMounted ? items.map((item, index) => {
425
+ const selected = index === selectedIndex;
426
+ const disabled = Boolean(item.disabled);
427
+ const tabId = `${baseId}-tab-${index}`;
428
+ const panelId = `${baseId}-panel-${index}`;
429
+ const state = { index, selected, disabled };
430
+ return /* @__PURE__ */ jsx(
431
+ "section",
432
+ {
433
+ ref: (node) => {
434
+ if (selected) {
435
+ activePanelRef.current = node;
436
+ }
437
+ },
438
+ id: panelId,
439
+ className: "arc-tabs__panel",
440
+ role: "tabpanel",
441
+ "aria-labelledby": tabId,
442
+ "aria-hidden": !selected,
443
+ hidden: !selected,
444
+ children: renderPanelContent(item, state)
445
+ },
446
+ item.id
447
+ );
448
+ }) : null,
449
+ items.length > 0 && selectedItem !== void 0 && !keepMounted ? /* @__PURE__ */ jsx(
450
+ "section",
451
+ {
452
+ ref: (node) => {
453
+ activePanelRef.current = node;
454
+ },
455
+ id: `${baseId}-panel-${selectedIndex}`,
456
+ className: "arc-tabs__panel",
457
+ role: "tabpanel",
458
+ "aria-labelledby": `${baseId}-tab-${selectedIndex}`,
459
+ "aria-hidden": false,
460
+ children: renderPanelContent(selectedItem, {
461
+ index: selectedIndex,
462
+ selected: true,
463
+ disabled: Boolean(selectedItem.disabled)
464
+ })
465
+ }
466
+ ) : null
467
+ ] })
468
+ ] });
469
+ }
470
+
471
+ // src/ArcTabsTailwind.tsx
472
+ import * as React2 from "react";
473
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
474
+ var joinClassNames2 = (...parts) => parts.filter(Boolean).join(" ");
475
+ var toCssSize2 = (value) => typeof value === "number" ? `${value}px` : value;
476
+ var findFirstEnabledIndex2 = (items) => items.findIndex((item) => !item.disabled);
477
+ var getEnabledIndices2 = (items) => items.reduce((acc, item, index) => {
478
+ if (!item.disabled) acc.push(index);
479
+ return acc;
480
+ }, []);
481
+ var getNextEnabledIndex2 = (enabledIndices, currentIndex, direction) => {
482
+ if (!enabledIndices.length) return -1;
483
+ const currentPosition = enabledIndices.indexOf(currentIndex);
484
+ if (currentPosition === -1) {
485
+ return direction === 1 ? enabledIndices[0] ?? -1 : enabledIndices[enabledIndices.length - 1] ?? -1;
486
+ }
487
+ const nextPosition = (currentPosition + direction + enabledIndices.length) % enabledIndices.length;
488
+ return enabledIndices[nextPosition] ?? -1;
489
+ };
490
+ var sizeClassMap = {
491
+ sm: "min-h-9 px-3 py-1.5 text-sm",
492
+ md: "min-h-10 px-4 py-2 text-[0.95rem]",
493
+ lg: "min-h-12 px-5 py-2.5 text-base"
494
+ };
495
+ function ArcTabsTailwind({
496
+ items,
497
+ value,
498
+ defaultValue,
499
+ onValueChange,
500
+ activationMode = "automatic",
501
+ keepMounted = true,
502
+ size = "md",
503
+ fit = "content",
504
+ motionPreset = "subtle",
505
+ motionDuration = 260,
506
+ ariaLabel = "Tabs",
507
+ listId,
508
+ tabsClassName,
509
+ panelClassName,
510
+ radius,
511
+ gap,
512
+ panelPadding,
513
+ accentColor,
514
+ tabBackground,
515
+ tabHoverBackground,
516
+ panelBackground,
517
+ panelBorderColor,
518
+ emptyState = null,
519
+ renderTabLabel,
520
+ renderPanel,
521
+ className,
522
+ style,
523
+ classNames,
524
+ ...rest
525
+ }) {
526
+ const reactId = React2.useId();
527
+ const baseId = React2.useMemo(
528
+ () => (listId ?? `arc-tabs-${reactId}`).replace(/:/g, ""),
529
+ [listId, reactId]
530
+ );
531
+ const isControlled = value !== void 0;
532
+ const firstEnabledIndex = React2.useMemo(
533
+ () => findFirstEnabledIndex2(items),
534
+ [items]
535
+ );
536
+ const [uncontrolledValue, setUncontrolledValue] = React2.useState(() => {
537
+ const requested = defaultValue;
538
+ const requestedMatch = items.find(
539
+ (item) => !item.disabled && item.id === requested
540
+ );
541
+ if (requestedMatch) return requestedMatch.id;
542
+ return firstEnabledIndex >= 0 ? items[firstEnabledIndex]?.id : void 0;
543
+ });
544
+ const rawValue = isControlled ? value : uncontrolledValue;
545
+ const strictSelectedIndex = React2.useMemo(
546
+ () => items.findIndex((item) => !item.disabled && item.id === rawValue),
547
+ [items, rawValue]
548
+ );
549
+ const selectedIndex = strictSelectedIndex >= 0 ? strictSelectedIndex : firstEnabledIndex;
550
+ const selectedItem = selectedIndex >= 0 ? items[selectedIndex] : void 0;
551
+ React2.useEffect(() => {
552
+ if (isControlled) return;
553
+ if (strictSelectedIndex !== -1) return;
554
+ if (firstEnabledIndex !== -1) {
555
+ const fallbackId = items[firstEnabledIndex]?.id;
556
+ setUncontrolledValue(fallbackId);
557
+ } else {
558
+ setUncontrolledValue(void 0);
559
+ }
560
+ }, [isControlled, strictSelectedIndex, firstEnabledIndex, items]);
561
+ const [focusedIndex, setFocusedIndex] = React2.useState(selectedIndex);
562
+ React2.useEffect(() => {
563
+ if (selectedIndex === -1) {
564
+ setFocusedIndex(-1);
565
+ return;
566
+ }
567
+ if (focusedIndex < 0 || focusedIndex >= items.length || items[focusedIndex]?.disabled) {
568
+ setFocusedIndex(selectedIndex);
569
+ }
570
+ }, [focusedIndex, selectedIndex, items]);
571
+ const enabledIndices = React2.useMemo(() => getEnabledIndices2(items), [items]);
572
+ const tabRefs = React2.useRef([]);
573
+ const listRef = React2.useRef(null);
574
+ const activePanelRef = React2.useRef(null);
575
+ const hasMountedRef = React2.useRef(false);
576
+ const previousSelectedIndexRef = React2.useRef(selectedIndex);
577
+ const [hasInteracted, setHasInteracted] = React2.useState(false);
578
+ const [panelDirection, setPanelDirection] = React2.useState("none");
579
+ const [indicator, setIndicator] = React2.useState({
580
+ x: 0,
581
+ width: 0,
582
+ ready: false
583
+ });
584
+ const effectiveMotionDuration = motionPreset === "none" ? 0 : Math.max(0, motionDuration);
585
+ const showSlidingIndicator = motionPreset === "expressive" && selectedIndex >= 0;
586
+ React2.useEffect(() => {
587
+ tabRefs.current = tabRefs.current.slice(0, items.length);
588
+ }, [items.length]);
589
+ React2.useEffect(() => {
590
+ const previous = previousSelectedIndexRef.current;
591
+ if (!hasMountedRef.current) {
592
+ hasMountedRef.current = true;
593
+ previousSelectedIndexRef.current = selectedIndex;
594
+ return;
595
+ }
596
+ if (previous !== selectedIndex) {
597
+ setHasInteracted(true);
598
+ if (selectedIndex >= 0 && previous >= 0) {
599
+ setPanelDirection(selectedIndex > previous ? "forward" : "backward");
600
+ }
601
+ }
602
+ previousSelectedIndexRef.current = selectedIndex;
603
+ }, [selectedIndex]);
604
+ const focusTabIndex = React2.useCallback((index) => {
605
+ if (index < 0) return;
606
+ setFocusedIndex(index);
607
+ tabRefs.current[index]?.focus();
608
+ }, []);
609
+ const selectTab = React2.useCallback(
610
+ (index) => {
611
+ const item = items[index];
612
+ if (!item || item.disabled) return;
613
+ if (index === selectedIndex) {
614
+ setFocusedIndex(index);
615
+ return;
616
+ }
617
+ setHasInteracted(true);
618
+ if (selectedIndex >= 0) {
619
+ setPanelDirection(index > selectedIndex ? "forward" : "backward");
620
+ }
621
+ if (!isControlled) {
622
+ setUncontrolledValue(item.id);
623
+ }
624
+ setFocusedIndex(index);
625
+ onValueChange?.(item.id, item, index);
626
+ },
627
+ [items, selectedIndex, isControlled, onValueChange]
628
+ );
629
+ const syncIndicator = React2.useCallback(() => {
630
+ if (!showSlidingIndicator) {
631
+ setIndicator(
632
+ (previous) => previous.ready || previous.width !== 0 || previous.x !== 0 ? { x: 0, width: 0, ready: false } : previous
633
+ );
634
+ return;
635
+ }
636
+ const listElement = listRef.current;
637
+ const selectedTab = selectedIndex >= 0 ? tabRefs.current[selectedIndex] ?? null : null;
638
+ if (!listElement || !selectedTab) return;
639
+ const listRect = listElement.getBoundingClientRect();
640
+ const tabRect = selectedTab.getBoundingClientRect();
641
+ const nextX = tabRect.left - listRect.left + listElement.scrollLeft;
642
+ const nextWidth = tabRect.width;
643
+ setIndicator((previous) => {
644
+ const changedX = Math.abs(previous.x - nextX) > 0.5;
645
+ const changedWidth = Math.abs(previous.width - nextWidth) > 0.5;
646
+ if (!changedX && !changedWidth && previous.ready) {
647
+ return previous;
648
+ }
649
+ return {
650
+ x: nextX,
651
+ width: nextWidth,
652
+ ready: true
653
+ };
654
+ });
655
+ }, [selectedIndex, showSlidingIndicator]);
656
+ React2.useEffect(() => {
657
+ syncIndicator();
658
+ }, [syncIndicator, items.length, size, fit]);
659
+ React2.useEffect(() => {
660
+ if (!showSlidingIndicator) return;
661
+ const listElement = listRef.current;
662
+ if (!listElement) return;
663
+ const onResize = () => syncIndicator();
664
+ const onScroll = () => syncIndicator();
665
+ const frame = requestAnimationFrame(syncIndicator);
666
+ let observer = null;
667
+ if (typeof ResizeObserver !== "undefined") {
668
+ observer = new ResizeObserver(() => syncIndicator());
669
+ observer.observe(listElement);
670
+ tabRefs.current.forEach((tabElement) => {
671
+ if (tabElement) observer?.observe(tabElement);
672
+ });
673
+ }
674
+ listElement.addEventListener("scroll", onScroll, { passive: true });
675
+ window.addEventListener("resize", onResize);
676
+ return () => {
677
+ cancelAnimationFrame(frame);
678
+ listElement.removeEventListener("scroll", onScroll);
679
+ window.removeEventListener("resize", onResize);
680
+ observer?.disconnect();
681
+ };
682
+ }, [showSlidingIndicator, syncIndicator, items.length]);
683
+ React2.useEffect(() => {
684
+ if (!hasInteracted || motionPreset === "none" || effectiveMotionDuration <= 0) {
685
+ return;
686
+ }
687
+ const panelElement = activePanelRef.current;
688
+ if (!panelElement || typeof panelElement.animate !== "function") {
689
+ return;
690
+ }
691
+ if (typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
692
+ return;
693
+ }
694
+ const offsetX = motionPreset === "expressive" ? panelDirection === "forward" ? 20 : panelDirection === "backward" ? -20 : 0 : 0;
695
+ const offsetY = motionPreset === "expressive" ? 10 : 6;
696
+ const startScale = motionPreset === "expressive" ? 0.985 : 0.995;
697
+ const animation = panelElement.animate(
698
+ [
699
+ {
700
+ opacity: 0,
701
+ transform: `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${startScale})`,
702
+ filter: "blur(1px)"
703
+ },
704
+ {
705
+ opacity: 1,
706
+ transform: "translate3d(0, 0, 0) scale(1)",
707
+ filter: "blur(0px)"
708
+ }
709
+ ],
710
+ {
711
+ duration: effectiveMotionDuration,
712
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)",
713
+ fill: "both"
714
+ }
715
+ );
716
+ return () => {
717
+ animation.cancel();
718
+ };
719
+ }, [
720
+ selectedIndex,
721
+ hasInteracted,
722
+ motionPreset,
723
+ panelDirection,
724
+ effectiveMotionDuration
725
+ ]);
726
+ const handleTabKeyDown = React2.useCallback(
727
+ (event, index) => {
728
+ if (!enabledIndices.length) return;
729
+ switch (event.key) {
730
+ case "ArrowRight": {
731
+ event.preventDefault();
732
+ const next = getNextEnabledIndex2(enabledIndices, index, 1);
733
+ if (next !== -1) {
734
+ focusTabIndex(next);
735
+ if (activationMode === "automatic") selectTab(next);
736
+ }
737
+ break;
738
+ }
739
+ case "ArrowLeft": {
740
+ event.preventDefault();
741
+ const previous = getNextEnabledIndex2(enabledIndices, index, -1);
742
+ if (previous !== -1) {
743
+ focusTabIndex(previous);
744
+ if (activationMode === "automatic") selectTab(previous);
745
+ }
746
+ break;
747
+ }
748
+ case "Home": {
749
+ event.preventDefault();
750
+ const first = enabledIndices[0];
751
+ if (first !== void 0) {
752
+ focusTabIndex(first);
753
+ if (activationMode === "automatic") selectTab(first);
754
+ }
755
+ break;
756
+ }
757
+ case "End": {
758
+ event.preventDefault();
759
+ const last = enabledIndices[enabledIndices.length - 1];
760
+ if (last !== void 0) {
761
+ focusTabIndex(last);
762
+ if (activationMode === "automatic") selectTab(last);
763
+ }
764
+ break;
765
+ }
766
+ case "Enter":
767
+ case " ":
768
+ case "Spacebar": {
769
+ if (activationMode === "manual") {
770
+ event.preventDefault();
771
+ selectTab(index);
772
+ }
773
+ break;
774
+ }
775
+ default:
776
+ break;
777
+ }
778
+ },
779
+ [activationMode, enabledIndices, focusTabIndex, selectTab]
780
+ );
781
+ const themedStyle = React2.useMemo(() => {
782
+ const cssVars = { ...style };
783
+ if (radius !== void 0) {
784
+ cssVars["--arc-radius"] = `${radius}px`;
785
+ }
786
+ if (gap !== void 0) {
787
+ cssVars["--arc-gap"] = `${gap}px`;
788
+ }
789
+ const panelPaddingValue = toCssSize2(panelPadding);
790
+ if (panelPaddingValue !== void 0) {
791
+ cssVars["--arc-panel-padding"] = panelPaddingValue;
792
+ }
793
+ if (accentColor) {
794
+ cssVars["--arc-accent"] = accentColor;
795
+ }
796
+ if (tabBackground) {
797
+ cssVars["--arc-tab-bg"] = tabBackground;
798
+ }
799
+ if (tabHoverBackground) {
800
+ cssVars["--arc-tab-hover-bg"] = tabHoverBackground;
801
+ }
802
+ if (panelBackground) {
803
+ cssVars["--arc-panel-bg"] = panelBackground;
804
+ }
805
+ if (panelBorderColor) {
806
+ cssVars["--arc-panel-border"] = panelBorderColor;
807
+ }
808
+ cssVars["--arc-motion-duration"] = `${effectiveMotionDuration}ms`;
809
+ return cssVars;
810
+ }, [
811
+ style,
812
+ radius,
813
+ gap,
814
+ panelPadding,
815
+ accentColor,
816
+ tabBackground,
817
+ tabHoverBackground,
818
+ panelBackground,
819
+ panelBorderColor,
820
+ effectiveMotionDuration
821
+ ]);
822
+ const rootClassName = joinClassNames2(
823
+ "arc-tabs-tw w-full text-[var(--arc-text)] [--arc-radius:14px] [--arc-gap:10px] [--arc-border-width:1px] [--arc-accent:#5b4ff1] [--arc-text:#171a2c] [--arc-tab-bg:#e7ebff] [--arc-tab-hover-bg:#dce3ff] [--arc-panel-bg:#ffffff] [--arc-panel-border:#cfd6f5] [--arc-panel-padding:1rem] [--arc-motion-duration:260ms] dark:[--arc-text:#edf1ff] dark:[--arc-tab-bg:#2c3555] dark:[--arc-tab-hover-bg:#374268] dark:[--arc-panel-bg:#1c243b] dark:[--arc-panel-border:#46527e]",
824
+ classNames?.root,
825
+ className
826
+ );
827
+ const listClassName = joinClassNames2(
828
+ "relative m-0 flex list-none items-end gap-[var(--arc-gap)] overflow-x-auto overflow-y-clip p-0 pb-[var(--arc-gap)] isolate",
829
+ classNames?.list,
830
+ tabsClassName
831
+ );
832
+ const panelsClassName = joinClassNames2(
833
+ "relative z-[2] mt-0 rounded-[var(--arc-radius)] border border-[var(--arc-panel-border)] bg-[var(--arc-panel-bg)] p-[var(--arc-panel-padding)] shadow-[0_12px_32px_rgba(15,23,42,0.12)]",
834
+ classNames?.panels,
835
+ panelClassName
836
+ );
837
+ const renderDefaultLabel = (item) => /* @__PURE__ */ jsxs2(Fragment2, { children: [
838
+ item.icon ? /* @__PURE__ */ jsx2("span", { className: joinClassNames2("inline-flex leading-none", classNames?.icon), children: item.icon }) : null,
839
+ /* @__PURE__ */ jsx2("span", { className: joinClassNames2("inline-block", classNames?.text), children: item.label }),
840
+ item.badge !== void 0 ? /* @__PURE__ */ jsx2(
841
+ "span",
842
+ {
843
+ className: joinClassNames2(
844
+ "inline-flex min-w-5 items-center justify-center rounded-full bg-[var(--arc-accent)] px-1.5 py-0.5 text-[0.72em] font-bold text-white/95",
845
+ classNames?.badge
846
+ ),
847
+ children: item.badge
848
+ }
849
+ ) : null
850
+ ] });
851
+ const renderPanelContent = (item, state) => renderPanel ? renderPanel(item, state) : item.content;
852
+ const indicatorStyle = React2.useMemo(
853
+ () => ({
854
+ "--arc-indicator-x": `${indicator.x}px`,
855
+ "--arc-indicator-w": `${indicator.width}px`
856
+ }),
857
+ [indicator.x, indicator.width]
858
+ );
859
+ const indicatorClassName = joinClassNames2(
860
+ "pointer-events-none absolute left-0 top-0 z-[1] h-[calc(100%-var(--arc-gap))] w-[var(--arc-indicator-w)] translate-x-[var(--arc-indicator-x)] rounded-t-[var(--arc-radius)] rounded-b-none bg-[var(--arc-panel-bg)] opacity-0 [box-shadow:0_calc(var(--arc-gap)+var(--arc-border-width))_0_var(--arc-panel-bg)] transition-[transform,width,opacity] [transition-duration:var(--arc-motion-duration)] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
861
+ indicator.ready && "opacity-100",
862
+ classNames?.indicator
863
+ );
864
+ return /* @__PURE__ */ jsxs2("div", { className: rootClassName, style: themedStyle, "data-slot": "root", ...rest, children: [
865
+ /* @__PURE__ */ jsxs2(
866
+ "ul",
867
+ {
868
+ ref: listRef,
869
+ className: listClassName,
870
+ role: "tablist",
871
+ "aria-label": ariaLabel,
872
+ id: `${baseId}-list`,
873
+ "data-slot": "list",
874
+ children: [
875
+ showSlidingIndicator ? /* @__PURE__ */ jsx2(
876
+ "li",
877
+ {
878
+ "aria-hidden": "true",
879
+ role: "presentation",
880
+ className: indicatorClassName,
881
+ style: indicatorStyle,
882
+ "data-slot": "indicator"
883
+ }
884
+ ) : null,
885
+ items.map((item, index) => {
886
+ const selected = index === selectedIndex;
887
+ const disabled = Boolean(item.disabled);
888
+ const tabId = `${baseId}-tab-${index}`;
889
+ const panelId = `${baseId}-panel-${index}`;
890
+ const state = { index, selected, disabled };
891
+ const tabIndexValue = disabled ? -1 : focusedIndex === index || focusedIndex === -1 && selected ? 0 : -1;
892
+ const itemClassName = joinClassNames2(
893
+ fit === "equal" ? "relative z-[2] min-w-0 flex-1" : "relative z-[2] shrink-0",
894
+ classNames?.item
895
+ );
896
+ const tabClassName = joinClassNames2(
897
+ "relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--arc-radius)] border border-[var(--arc-panel-border)] bg-[var(--arc-tab-bg)] text-inherit font-semibold leading-none select-none transition-[background-color,color,transform,border-color,box-shadow] [transition-duration:var(--arc-motion-duration)] [transition-timing-function:cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--arc-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--arc-panel-bg)] disabled:cursor-not-allowed disabled:opacity-45 before:pointer-events-none before:absolute before:bottom-0 before:left-[calc(var(--arc-border-width)*-1)] before:h-[calc(var(--arc-radius)*2)] before:w-[calc(var(--arc-radius)+var(--arc-border-width))] before:translate-y-[calc(var(--arc-gap)+var(--arc-border-width))] before:bg-transparent before:opacity-0 before:content-[''] before:[box-shadow:0_var(--arc-radius)_0_var(--arc-panel-bg)] before:transition-[opacity,transform] before:[transition-duration:var(--arc-motion-duration)] before:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)] after:pointer-events-none after:absolute after:bottom-0 after:right-[calc(var(--arc-border-width)*-1)] after:h-[calc(var(--arc-radius)*2)] after:w-[calc(var(--arc-radius)+var(--arc-border-width))] after:translate-y-[calc(var(--arc-gap)+var(--arc-border-width))] after:bg-transparent after:opacity-0 after:content-[''] after:[box-shadow:0_var(--arc-radius)_0_var(--arc-panel-bg)] after:transition-[opacity,transform] after:[transition-duration:var(--arc-motion-duration)] after:[transition-timing-function:cubic-bezier(0.22,1,0.36,1)]",
898
+ sizeClassMap[size],
899
+ fit === "equal" && "w-full justify-center",
900
+ selected ? joinClassNames2(
901
+ "z-[3] rounded-b-none border-[var(--arc-panel-bg)] text-[var(--arc-accent)] before:opacity-100 after:opacity-100 [box-shadow:0_calc(var(--arc-gap)+var(--arc-border-width))_0_var(--arc-panel-bg)]",
902
+ motionPreset === "expressive" ? "bg-transparent" : "bg-[var(--arc-panel-bg)]"
903
+ ) : "enabled:hover:bg-[var(--arc-tab-hover-bg)] enabled:hover:translate-y-px enabled:active:translate-y-[2px]",
904
+ index > 0 && "before:-translate-x-full before:translate-y-[calc(var(--arc-gap)+var(--arc-border-width))] before:rounded-br-[var(--arc-radius)] before:[box-shadow:var(--arc-border-width)_var(--arc-radius)_0_var(--arc-panel-bg)]",
905
+ index < items.length - 1 && "after:translate-x-full after:translate-y-[calc(var(--arc-gap)+var(--arc-border-width))] after:rounded-bl-[var(--arc-radius)] after:[box-shadow:calc(var(--arc-border-width)*-1)_var(--arc-radius)_0_var(--arc-panel-bg)]",
906
+ selected ? classNames?.tabSelected : classNames?.tabUnselected,
907
+ disabled && classNames?.tabDisabled,
908
+ classNames?.tab
909
+ );
910
+ return /* @__PURE__ */ jsx2("li", { className: itemClassName, role: "presentation", "data-slot": "item", children: /* @__PURE__ */ jsx2(
911
+ "button",
912
+ {
913
+ id: tabId,
914
+ ref: (node) => {
915
+ tabRefs.current[index] = node;
916
+ },
917
+ type: "button",
918
+ role: "tab",
919
+ "aria-selected": selected,
920
+ "aria-controls": panelId,
921
+ tabIndex: tabIndexValue,
922
+ disabled,
923
+ className: tabClassName,
924
+ onFocus: () => setFocusedIndex(index),
925
+ onClick: () => selectTab(index),
926
+ onKeyDown: (event) => handleTabKeyDown(event, index),
927
+ "data-slot": "tab",
928
+ children: renderTabLabel ? renderTabLabel(item, state) : renderDefaultLabel(item)
929
+ }
930
+ ) }, item.id);
931
+ })
932
+ ]
933
+ }
934
+ ),
935
+ /* @__PURE__ */ jsxs2("div", { className: panelsClassName, "data-slot": "panels", children: [
936
+ items.length === 0 && emptyState,
937
+ items.length > 0 && selectedItem === void 0 && emptyState,
938
+ items.length > 0 && selectedItem !== void 0 && keepMounted ? items.map((item, index) => {
939
+ const selected = index === selectedIndex;
940
+ const disabled = Boolean(item.disabled);
941
+ const tabId = `${baseId}-tab-${index}`;
942
+ const panelId = `${baseId}-panel-${index}`;
943
+ const state = { index, selected, disabled };
944
+ return /* @__PURE__ */ jsx2(
945
+ "section",
946
+ {
947
+ ref: (node) => {
948
+ if (selected) {
949
+ activePanelRef.current = node;
950
+ }
951
+ },
952
+ id: panelId,
953
+ className: joinClassNames2("outline-none", classNames?.panel),
954
+ role: "tabpanel",
955
+ "aria-labelledby": tabId,
956
+ "aria-hidden": !selected,
957
+ hidden: !selected,
958
+ "data-slot": "panel",
959
+ children: renderPanelContent(item, state)
960
+ },
961
+ item.id
962
+ );
963
+ }) : null,
964
+ items.length > 0 && selectedItem !== void 0 && !keepMounted ? /* @__PURE__ */ jsx2(
965
+ "section",
966
+ {
967
+ ref: (node) => {
968
+ activePanelRef.current = node;
969
+ },
970
+ id: `${baseId}-panel-${selectedIndex}`,
971
+ className: joinClassNames2("outline-none", classNames?.panel),
972
+ role: "tabpanel",
973
+ "aria-labelledby": `${baseId}-tab-${selectedIndex}`,
974
+ "aria-hidden": false,
975
+ "data-slot": "panel",
976
+ children: renderPanelContent(selectedItem, {
977
+ index: selectedIndex,
978
+ selected: true,
979
+ disabled: Boolean(selectedItem.disabled)
980
+ })
981
+ }
982
+ ) : null
983
+ ] })
984
+ ] });
985
+ }
986
+ export {
987
+ ArcTabs,
988
+ ArcTabsTailwind
989
+ };
990
+ //# sourceMappingURL=index.js.map