@versini/ui-tabs 1.1.0 → 1.1.1
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/LICENSE +21 -0
- package/dist/components/index.d.ts +3 -2
- package/dist/components/index.js +74 -55
- package/package.json +4 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Arno Versini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -32,6 +32,7 @@ export declare type Size = "small" | "medium" | "large";
|
|
|
32
32
|
* orphan TabsTrigger (no matching panel) renders without `aria-controls`.
|
|
33
33
|
* 9. DOM-order traversal — triggers register in DOM order (compareDocumentPosition)
|
|
34
34
|
* so keyboard navigation matches visual order.
|
|
35
|
+
*
|
|
35
36
|
*/
|
|
36
37
|
export declare function Tabs<Value extends string = string>({ children, value, defaultValue, onValueChange, orientation, activationMode, size, mode, className, }: TabsProps<Value>): JSX.Element;
|
|
37
38
|
|
|
@@ -120,8 +121,8 @@ export declare type TabsProps<Value extends string = string> = {
|
|
|
120
121
|
size?: Size;
|
|
121
122
|
/**
|
|
122
123
|
* How tab/panel text adapts to its surface. Use `"light"`/`"dark"` to force
|
|
123
|
-
* colors for a surface whose lightness differs from the ambient theme (e.g.
|
|
124
|
-
*
|
|
124
|
+
* colors for a surface whose lightness differs from the ambient theme (e.g. a
|
|
125
|
+
* light card inside a dark-mode page); `"system"` follows the ambient theme,
|
|
125
126
|
* `"alt-system"` inverts it.
|
|
126
127
|
* @default "system"
|
|
127
128
|
*/
|
package/dist/components/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
@versini/ui-tabs v1.1.
|
|
2
|
+
@versini/ui-tabs v1.1.1
|
|
3
3
|
© 2026 gizmette.com
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -81,19 +81,21 @@ const getTabsTriggerSizeClasses = ({ size })=>{
|
|
|
81
81
|
};
|
|
82
82
|
const getTabsTriggerClasses = ({ size, orientation, active, mode })=>{
|
|
83
83
|
const borderSide = orientation === "vertical" ? "border-l-2" : "border-b-2";
|
|
84
|
-
return clsx(TABS_TRIGGER_CLASSNAME, "relative cursor-pointer bg-transparent transition-colors outline-none",
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
return clsx(TABS_TRIGGER_CLASSNAME, "relative cursor-pointer bg-transparent transition-colors outline-none", /**
|
|
85
|
+
* High-contrast text that matches the surface (via mode). The active state is
|
|
86
|
+
* conveyed by the indicator border + bold weight, never by dimming the
|
|
87
|
+
* resting text. Matches clubroad's full-contrast inactive tabs.
|
|
88
|
+
*/ getModeTextClasses({
|
|
88
89
|
mode
|
|
89
90
|
}), "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus-dark", "disabled:cursor-not-allowed disabled:opacity-50", getTabsTriggerSizeClasses({
|
|
90
91
|
size
|
|
91
92
|
}), borderSide, active ? "border-action-light" : "border-transparent");
|
|
92
93
|
};
|
|
93
94
|
const getTabsContentClasses = ({ className, mode })=>{
|
|
94
|
-
return clsx(TABS_CONTENT_CLASSNAME,
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
return clsx(TABS_CONTENT_CLASSNAME, /**
|
|
96
|
+
* Theme-aware text so panel content stays readable on the actual surface
|
|
97
|
+
* (consumers can still override via className).
|
|
98
|
+
*/ getModeTextClasses({
|
|
97
99
|
mode
|
|
98
100
|
}), "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus-dark", className);
|
|
99
101
|
};
|
|
@@ -105,7 +107,10 @@ const getTabsContentClasses = ({ className, mode })=>{
|
|
|
105
107
|
|
|
106
108
|
|
|
107
109
|
|
|
108
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Nearest enabled trigger to `fromIndex`: scan forward (next), then backward
|
|
112
|
+
* (previous).
|
|
113
|
+
*/ const findNearestEnabledIndex = (triggers, fromIndex)=>{
|
|
109
114
|
const count = triggers.length;
|
|
110
115
|
const start = Math.min(Math.max(fromIndex, 0), count);
|
|
111
116
|
for(let i = start; i < count; i++){
|
|
@@ -144,6 +149,7 @@ const getTabsContentClasses = ({ className, mode })=>{
|
|
|
144
149
|
* orphan TabsTrigger (no matching panel) renders without `aria-controls`.
|
|
145
150
|
* 9. DOM-order traversal — triggers register in DOM order (compareDocumentPosition)
|
|
146
151
|
* so keyboard navigation matches visual order.
|
|
152
|
+
*
|
|
147
153
|
*/ function Tabs({ children, value, defaultValue, onValueChange, orientation = "horizontal", activationMode = "automatic", size = "medium", mode = "system", className }) {
|
|
148
154
|
const [activeValue, setActiveValue] = useUncontrolled({
|
|
149
155
|
value,
|
|
@@ -154,19 +160,22 @@ const getTabsContentClasses = ({ className, mode })=>{
|
|
|
154
160
|
const triggersRef = useRef([]);
|
|
155
161
|
const panelsRef = useRef(new Set());
|
|
156
162
|
const focusWithinRef = useRef(false);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Last known index of the active trigger, used to recover to the *nearest*
|
|
165
|
+
* trigger when the active one is removed (its value is gone from the registry,
|
|
166
|
+
* but this ref still holds the slot it occupied).
|
|
167
|
+
*/ const lastActiveIndexRef = useRef(0);
|
|
168
|
+
/**
|
|
169
|
+
* Bumped whenever a trigger/panel registers or unregisters; it is a dependency
|
|
170
|
+
* of the context memo so consumers re-render and recompute roving tabIndex /
|
|
171
|
+
* aria-controls from the (mutable) registries.
|
|
172
|
+
*/ const [registrationVersion, setRegistrationVersion] = useState(0);
|
|
165
173
|
const bumpVersion = useCallback(()=>setRegistrationVersion((version)=>version + 1), []);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Stable activation setter (useUncontrolled returns a fresh setter each render
|
|
176
|
+
* in uncontrolled mode; a ref keeps `setValue`'s identity stable so the
|
|
177
|
+
* context memo is not invalidated on every render).
|
|
178
|
+
*/ const setActiveRef = useRef(setActiveValue);
|
|
170
179
|
setActiveRef.current = setActiveValue;
|
|
171
180
|
const setValue = useCallback((next)=>{
|
|
172
181
|
setActiveRef.current(next);
|
|
@@ -216,16 +225,18 @@ const getTabsContentClasses = ({ className, mode })=>{
|
|
|
216
225
|
focusWithinRef.current = true;
|
|
217
226
|
}, []);
|
|
218
227
|
const handleRootBlur = useCallback((event)=>{
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Keep the flag when focus is lost to <body> (e.g. the focused trigger
|
|
230
|
+
* unmounts) so recovery can restore it; only clear when focus genuinely moves
|
|
231
|
+
* to an element outside this Tabs.
|
|
232
|
+
*/ if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) {
|
|
223
233
|
focusWithinRef.current = false;
|
|
224
234
|
}
|
|
225
235
|
}, []);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Default resolution (Invariant 3) + dynamic recovery (Invariant 7), both
|
|
238
|
+
* uncontrolled-only (controlled mode delegates recovery to the consumer).
|
|
239
|
+
*/ useIsomorphicLayoutEffect(()=>{
|
|
229
240
|
if (value !== undefined) {
|
|
230
241
|
return;
|
|
231
242
|
}
|
|
@@ -238,9 +249,10 @@ const getTabsContentClasses = ({ className, mode })=>{
|
|
|
238
249
|
if (recoveredIndex !== -1) {
|
|
239
250
|
const recovered = triggers[recoveredIndex];
|
|
240
251
|
setActiveValue(recovered.value);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
252
|
+
/**
|
|
253
|
+
* Restore keyboard position if focus was inside the tablist (e.g. the
|
|
254
|
+
* focused active trigger was just removed and focus fell to <body>).
|
|
255
|
+
*/ if (focusWithinRef.current) {
|
|
244
256
|
recovered.element.focus();
|
|
245
257
|
}
|
|
246
258
|
}
|
|
@@ -250,10 +262,11 @@ const getTabsContentClasses = ({ className, mode })=>{
|
|
|
250
262
|
if (activeIndex !== -1) {
|
|
251
263
|
lastActiveIndexRef.current = activeIndex;
|
|
252
264
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Exactly one trigger carries tabIndex=0 (Invariant 2): the active trigger if
|
|
267
|
+
* it matches one (even when disabled, Invariant 5), else the first enabled,
|
|
268
|
+
* else the first.
|
|
269
|
+
*/ let tabbableValue;
|
|
257
270
|
if (activeIndex !== -1) {
|
|
258
271
|
tabbableValue = activeValue;
|
|
259
272
|
} else {
|
|
@@ -278,9 +291,10 @@ const getTabsContentClasses = ({ className, mode })=>{
|
|
|
278
291
|
}), [
|
|
279
292
|
activeValue,
|
|
280
293
|
tabbableValue,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
294
|
+
/**
|
|
295
|
+
* registrationVersion drives recompute of roving tabIndex / aria-controls
|
|
296
|
+
* from the registries when a trigger/panel (un)registers.
|
|
297
|
+
*/ registrationVersion,
|
|
284
298
|
setValue,
|
|
285
299
|
orientation,
|
|
286
300
|
activationMode,
|
|
@@ -326,10 +340,11 @@ function TabsContent({ value, children, className, ...rest }) {
|
|
|
326
340
|
registerPanel,
|
|
327
341
|
unregisterPanel
|
|
328
342
|
]);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
343
|
+
/**
|
|
344
|
+
* Only the active panel renders, and only when a matching trigger exists (an
|
|
345
|
+
* orphan TabsContent never renders). Check active first so inactive panels
|
|
346
|
+
* skip the trigger-registry scan.
|
|
347
|
+
*/ if (activeValue !== value) {
|
|
333
348
|
return null;
|
|
334
349
|
}
|
|
335
350
|
const hasMatchingTrigger = getTriggers().some((trigger)=>trigger.value === value);
|
|
@@ -356,9 +371,10 @@ TabsContent.displayName = "TabsContent";
|
|
|
356
371
|
|
|
357
372
|
const getNextEnabledIndex = (triggers, currentIndex, direction)=>{
|
|
358
373
|
const count = triggers.length;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Seed the sentinel per direction so the first forward step lands on 0 and the
|
|
376
|
+
* first backward step lands on count-1 (correct wrap when nothing is current).
|
|
377
|
+
*/ let index = currentIndex < 0 ? direction === 1 ? -1 : count : currentIndex;
|
|
362
378
|
for(let i = 0; i < count; i++){
|
|
363
379
|
index = (index + direction + count) % count;
|
|
364
380
|
if (!triggers[index].disabled) {
|
|
@@ -436,10 +452,11 @@ const getLastEnabledIndex = (triggers)=>{
|
|
|
436
452
|
case "Enter":
|
|
437
453
|
case " ":
|
|
438
454
|
{
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
455
|
+
/**
|
|
456
|
+
* Prevent the native <button> click (and Space-scroll) so we own
|
|
457
|
+
* activation. In manual mode, activate the focused trigger; in automatic
|
|
458
|
+
* mode the tab is already active, so this is a no-op.
|
|
459
|
+
*/ event.preventDefault();
|
|
443
460
|
if (activationMode === "manual" && currentIndex >= 0 && !triggers[currentIndex].disabled) {
|
|
444
461
|
setValue(triggers[currentIndex].value);
|
|
445
462
|
}
|
|
@@ -510,9 +527,10 @@ TabsList.displayName = "TabsList";
|
|
|
510
527
|
function TabsTrigger({ value, children, disabled = false, className, onClick, ...rest }) {
|
|
511
528
|
const { value: activeValue, tabbableValue, setValue, orientation, size, mode, baseId, registerTrigger, unregisterTrigger, hasPanel } = useContext(TabsContext);
|
|
512
529
|
const buttonRef = useRef(null);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
530
|
+
/**
|
|
531
|
+
* Register in a layout effect (before paint) so the panel's first render sees
|
|
532
|
+
* a populated trigger registry — avoids a one-frame empty/flicker state.
|
|
533
|
+
*/ useIsomorphicLayoutEffect(()=>{
|
|
516
534
|
const element = buttonRef.current;
|
|
517
535
|
/* v8 ignore start - ref is always set after mount */ if (!element) {
|
|
518
536
|
return;
|
|
@@ -534,9 +552,10 @@ function TabsTrigger({ value, children, disabled = false, className, onClick, ..
|
|
|
534
552
|
const active = activeValue === value;
|
|
535
553
|
const tabId = getTabId(baseId, value);
|
|
536
554
|
const panelId = getPanelId(baseId, value);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Emit aria-controls only on the active trigger and only when its panel is
|
|
557
|
+
* registered (avoids a dangling IDREF; supports trigger-only/navigation use).
|
|
558
|
+
*/ const controlsPanel = active && hasPanel(value);
|
|
540
559
|
return /*#__PURE__*/ jsx("button", {
|
|
541
560
|
...rest,
|
|
542
561
|
ref: buttonRef,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@versini/ui-tabs",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Arno Versini",
|
|
6
6
|
"publishConfig": {
|
|
@@ -41,11 +41,12 @@
|
|
|
41
41
|
"test:visual:ui": "playwright test -c playwright-ct.config.ts --ui"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@versini/ui-hooks": "
|
|
44
|
+
"@versini/ui-hooks": "6.1.1",
|
|
45
45
|
"clsx": "2.1.1",
|
|
46
46
|
"tailwindcss": "4.3.0"
|
|
47
47
|
},
|
|
48
48
|
"sideEffects": [
|
|
49
49
|
"**/*.css"
|
|
50
|
-
]
|
|
50
|
+
],
|
|
51
|
+
"gitHead": "4d011eb94af9a5685d315e7c965fdf85241635c1"
|
|
51
52
|
}
|