@versini/ui-tabs 1.1.0 → 1.2.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/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
- * a light card inside a dark-mode page); `"system"` follows the ambient theme,
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
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-tabs v1.1.0
2
+ @versini/ui-tabs v1.2.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -9,6 +9,8 @@ import { useUniqueId } from "@versini/ui-hooks/use-unique-id";
9
9
  import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
10
10
  import clsx from "clsx";
11
11
 
12
+
13
+
12
14
  const TABS_CLASSNAME = "av-tabs";
13
15
  const TABS_LIST_CLASSNAME = "av-tabs-list";
14
16
  const TABS_TRIGGER_CLASSNAME = "av-tabs-trigger";
@@ -19,6 +21,7 @@ const TABS_CONTENT_CLASSNAME = "av-tabs-content";
19
21
 
20
22
 
21
23
 
24
+
22
25
  /* v8 ignore start - default context values are fallbacks, never used directly */ const TabsContext = createContext({
23
26
  value: undefined,
24
27
  tabbableValue: undefined,
@@ -37,6 +40,7 @@ const TABS_CONTENT_CLASSNAME = "av-tabs-content";
37
40
  }); /* v8 ignore stop */
38
41
 
39
42
 
43
+
40
44
  /**
41
45
  * `useLayoutEffect` in the browser, `useEffect` on the server — avoids React's
42
46
  * "useLayoutEffect does nothing on the server" warning during SSR while keeping
@@ -75,25 +79,27 @@ const getTabsListClasses = ({ orientation })=>{
75
79
  const getTabsTriggerSizeClasses = ({ size })=>{
76
80
  return clsx({
77
81
  "text-xs px-2 py-1": size === "small",
78
- "text-sm px-4 py-2": size === "medium",
82
+ "text-sm px-2 sm:px-4 py-2": size === "medium",
79
83
  "text-base px-5 py-2.5": size === "large"
80
84
  });
81
85
  };
82
86
  const getTabsTriggerClasses = ({ size, orientation, active, mode })=>{
83
87
  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", // High-contrast text that matches the surface (via mode). The active state
85
- // is conveyed by the indicator border + bold weight, never by dimming the
86
- // resting text. Matches clubroad's full-contrast inactive tabs.
87
- getModeTextClasses({
88
+ return clsx(TABS_TRIGGER_CLASSNAME, "relative cursor-pointer bg-transparent transition-colors outline-none", /**
89
+ * High-contrast text that matches the surface (via mode). The active state is
90
+ * conveyed by the indicator border + bold weight, never by dimming the
91
+ * resting text. Matches clubroad's full-contrast inactive tabs.
92
+ */ getModeTextClasses({
88
93
  mode
89
94
  }), "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus-dark", "disabled:cursor-not-allowed disabled:opacity-50", getTabsTriggerSizeClasses({
90
95
  size
91
96
  }), borderSide, active ? "border-action-light" : "border-transparent");
92
97
  };
93
98
  const getTabsContentClasses = ({ className, mode })=>{
94
- return clsx(TABS_CONTENT_CLASSNAME, // Theme-aware text so panel content stays readable on the actual surface
95
- // (consumers can still override via className).
96
- getModeTextClasses({
99
+ return clsx(TABS_CONTENT_CLASSNAME, /**
100
+ * Theme-aware text so panel content stays readable on the actual surface
101
+ * (consumers can still override via className).
102
+ */ getModeTextClasses({
97
103
  mode
98
104
  }), "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus-dark", className);
99
105
  };
@@ -105,7 +111,10 @@ const getTabsContentClasses = ({ className, mode })=>{
105
111
 
106
112
 
107
113
 
108
- /** Nearest enabled trigger to `fromIndex`: scan forward (next), then backward (previous). */ const findNearestEnabledIndex = (triggers, fromIndex)=>{
114
+ /**
115
+ * Nearest enabled trigger to `fromIndex`: scan forward (next), then backward
116
+ * (previous).
117
+ */ const findNearestEnabledIndex = (triggers, fromIndex)=>{
109
118
  const count = triggers.length;
110
119
  const start = Math.min(Math.max(fromIndex, 0), count);
111
120
  for(let i = start; i < count; i++){
@@ -144,6 +153,7 @@ const getTabsContentClasses = ({ className, mode })=>{
144
153
  * orphan TabsTrigger (no matching panel) renders without `aria-controls`.
145
154
  * 9. DOM-order traversal — triggers register in DOM order (compareDocumentPosition)
146
155
  * so keyboard navigation matches visual order.
156
+ *
147
157
  */ function Tabs({ children, value, defaultValue, onValueChange, orientation = "horizontal", activationMode = "automatic", size = "medium", mode = "system", className }) {
148
158
  const [activeValue, setActiveValue] = useUncontrolled({
149
159
  value,
@@ -154,19 +164,22 @@ const getTabsContentClasses = ({ className, mode })=>{
154
164
  const triggersRef = useRef([]);
155
165
  const panelsRef = useRef(new Set());
156
166
  const focusWithinRef = useRef(false);
157
- // Last known index of the active trigger, used to recover to the *nearest*
158
- // trigger when the active one is removed (its value is gone from the registry,
159
- // but this ref still holds the slot it occupied).
160
- const lastActiveIndexRef = useRef(0);
161
- // Bumped whenever a trigger/panel registers or unregisters; it is a dependency
162
- // of the context memo so consumers re-render and recompute roving tabIndex /
163
- // aria-controls from the (mutable) registries.
164
- const [registrationVersion, setRegistrationVersion] = useState(0);
167
+ /**
168
+ * Last known index of the active trigger, used to recover to the *nearest*
169
+ * trigger when the active one is removed (its value is gone from the registry,
170
+ * but this ref still holds the slot it occupied).
171
+ */ const lastActiveIndexRef = useRef(0);
172
+ /**
173
+ * Bumped whenever a trigger/panel registers or unregisters; it is a dependency
174
+ * of the context memo so consumers re-render and recompute roving tabIndex /
175
+ * aria-controls from the (mutable) registries.
176
+ */ const [registrationVersion, setRegistrationVersion] = useState(0);
165
177
  const bumpVersion = useCallback(()=>setRegistrationVersion((version)=>version + 1), []);
166
- // Stable activation setter (useUncontrolled returns a fresh setter each render
167
- // in uncontrolled mode; a ref keeps `setValue`'s identity stable so the context
168
- // memo is not invalidated on every render).
169
- const setActiveRef = useRef(setActiveValue);
178
+ /**
179
+ * Stable activation setter (useUncontrolled returns a fresh setter each render
180
+ * in uncontrolled mode; a ref keeps `setValue`'s identity stable so the
181
+ * context memo is not invalidated on every render).
182
+ */ const setActiveRef = useRef(setActiveValue);
170
183
  setActiveRef.current = setActiveValue;
171
184
  const setValue = useCallback((next)=>{
172
185
  setActiveRef.current(next);
@@ -216,16 +229,18 @@ const getTabsContentClasses = ({ className, mode })=>{
216
229
  focusWithinRef.current = true;
217
230
  }, []);
218
231
  const handleRootBlur = useCallback((event)=>{
219
- // Keep the flag when focus is lost to <body> (e.g. the focused trigger
220
- // unmounts) so recovery can restore it; only clear when focus genuinely
221
- // moves to an element outside this Tabs.
222
- if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) {
232
+ /**
233
+ * Keep the flag when focus is lost to <body> (e.g. the focused trigger
234
+ * unmounts) so recovery can restore it; only clear when focus genuinely moves
235
+ * to an element outside this Tabs.
236
+ */ if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) {
223
237
  focusWithinRef.current = false;
224
238
  }
225
239
  }, []);
226
- // Default resolution (Invariant 3) + dynamic recovery (Invariant 7), both
227
- // uncontrolled-only (controlled mode delegates recovery to the consumer).
228
- useIsomorphicLayoutEffect(()=>{
240
+ /**
241
+ * Default resolution (Invariant 3) + dynamic recovery (Invariant 7), both
242
+ * uncontrolled-only (controlled mode delegates recovery to the consumer).
243
+ */ useIsomorphicLayoutEffect(()=>{
229
244
  if (value !== undefined) {
230
245
  return;
231
246
  }
@@ -238,9 +253,10 @@ const getTabsContentClasses = ({ className, mode })=>{
238
253
  if (recoveredIndex !== -1) {
239
254
  const recovered = triggers[recoveredIndex];
240
255
  setActiveValue(recovered.value);
241
- // Restore keyboard position if focus was inside the tablist (e.g. the
242
- // focused active trigger was just removed and focus fell to <body>).
243
- if (focusWithinRef.current) {
256
+ /**
257
+ * Restore keyboard position if focus was inside the tablist (e.g. the
258
+ * focused active trigger was just removed and focus fell to <body>).
259
+ */ if (focusWithinRef.current) {
244
260
  recovered.element.focus();
245
261
  }
246
262
  }
@@ -250,10 +266,11 @@ const getTabsContentClasses = ({ className, mode })=>{
250
266
  if (activeIndex !== -1) {
251
267
  lastActiveIndexRef.current = activeIndex;
252
268
  }
253
- // Exactly one trigger carries tabIndex=0 (Invariant 2): the active trigger if
254
- // it matches one (even when disabled, Invariant 5), else the first enabled,
255
- // else the first.
256
- let tabbableValue;
269
+ /**
270
+ * Exactly one trigger carries tabIndex=0 (Invariant 2): the active trigger if
271
+ * it matches one (even when disabled, Invariant 5), else the first enabled,
272
+ * else the first.
273
+ */ let tabbableValue;
257
274
  if (activeIndex !== -1) {
258
275
  tabbableValue = activeValue;
259
276
  } else {
@@ -278,9 +295,10 @@ const getTabsContentClasses = ({ className, mode })=>{
278
295
  }), [
279
296
  activeValue,
280
297
  tabbableValue,
281
- // registrationVersion drives recompute of roving tabIndex / aria-controls
282
- // from the registries when a trigger/panel (un)registers.
283
- registrationVersion,
298
+ /**
299
+ * registrationVersion drives recompute of roving tabIndex / aria-controls
300
+ * from the registries when a trigger/panel (un)registers.
301
+ */ registrationVersion,
284
302
  setValue,
285
303
  orientation,
286
304
  activationMode,
@@ -314,6 +332,8 @@ Tabs.displayName = "Tabs";
314
332
 
315
333
 
316
334
 
335
+
336
+
317
337
  function TabsContent({ value, children, className, ...rest }) {
318
338
  const { value: activeValue, mode, baseId, registerPanel, unregisterPanel, getTriggers } = useContext(TabsContext);
319
339
  useIsomorphicLayoutEffect(()=>{
@@ -326,10 +346,11 @@ function TabsContent({ value, children, className, ...rest }) {
326
346
  registerPanel,
327
347
  unregisterPanel
328
348
  ]);
329
- // Only the active panel renders, and only when a matching trigger exists
330
- // (an orphan TabsContent never renders). Check active first so inactive
331
- // panels skip the trigger-registry scan.
332
- if (activeValue !== value) {
349
+ /**
350
+ * Only the active panel renders, and only when a matching trigger exists (an
351
+ * orphan TabsContent never renders). Check active first so inactive panels
352
+ * skip the trigger-registry scan.
353
+ */ if (activeValue !== value) {
333
354
  return null;
334
355
  }
335
356
  const hasMatchingTrigger = getTriggers().some((trigger)=>trigger.value === value);
@@ -354,11 +375,16 @@ function TabsContent({ value, children, className, ...rest }) {
354
375
  TabsContent.displayName = "TabsContent";
355
376
 
356
377
 
378
+
379
+
380
+
381
+
357
382
  const getNextEnabledIndex = (triggers, currentIndex, direction)=>{
358
383
  const count = triggers.length;
359
- // Seed the sentinel per direction so the first forward step lands on 0 and the
360
- // first backward step lands on count-1 (correct wrap when nothing is current).
361
- let index = currentIndex < 0 ? direction === 1 ? -1 : count : currentIndex;
384
+ /**
385
+ * Seed the sentinel per direction so the first forward step lands on 0 and the
386
+ * first backward step lands on count-1 (correct wrap when nothing is current).
387
+ */ let index = currentIndex < 0 ? direction === 1 ? -1 : count : currentIndex;
362
388
  for(let i = 0; i < count; i++){
363
389
  index = (index + direction + count) % count;
364
390
  if (!triggers[index].disabled) {
@@ -436,10 +462,11 @@ const getLastEnabledIndex = (triggers)=>{
436
462
  case "Enter":
437
463
  case " ":
438
464
  {
439
- // Prevent the native <button> click (and Space-scroll) so we own
440
- // activation. In manual mode, activate the focused trigger; in
441
- // automatic mode the tab is already active, so this is a no-op.
442
- event.preventDefault();
465
+ /**
466
+ * Prevent the native <button> click (and Space-scroll) so we own
467
+ * activation. In manual mode, activate the focused trigger; in automatic
468
+ * mode the tab is already active, so this is a no-op.
469
+ */ event.preventDefault();
443
470
  if (activationMode === "manual" && currentIndex >= 0 && !triggers[currentIndex].disabled) {
444
471
  setValue(triggers[currentIndex].value);
445
472
  }
@@ -507,12 +534,16 @@ TabsList.displayName = "TabsList";
507
534
 
508
535
 
509
536
 
537
+
538
+
539
+
510
540
  function TabsTrigger({ value, children, disabled = false, className, onClick, ...rest }) {
511
541
  const { value: activeValue, tabbableValue, setValue, orientation, size, mode, baseId, registerTrigger, unregisterTrigger, hasPanel } = useContext(TabsContext);
512
542
  const buttonRef = useRef(null);
513
- // Register in a layout effect (before paint) so the panel's first render sees
514
- // a populated trigger registry avoids a one-frame empty/flicker state.
515
- useIsomorphicLayoutEffect(()=>{
543
+ /**
544
+ * Register in a layout effect (before paint) so the panel's first render sees
545
+ * a populated trigger registry — avoids a one-frame empty/flicker state.
546
+ */ useIsomorphicLayoutEffect(()=>{
516
547
  const element = buttonRef.current;
517
548
  /* v8 ignore start - ref is always set after mount */ if (!element) {
518
549
  return;
@@ -534,9 +565,10 @@ function TabsTrigger({ value, children, disabled = false, className, onClick, ..
534
565
  const active = activeValue === value;
535
566
  const tabId = getTabId(baseId, value);
536
567
  const panelId = getPanelId(baseId, value);
537
- // Emit aria-controls only on the active trigger and only when its panel is
538
- // registered (avoids a dangling IDREF; supports trigger-only/navigation use).
539
- const controlsPanel = active && hasPanel(value);
568
+ /**
569
+ * Emit aria-controls only on the active trigger and only when its panel is
570
+ * registered (avoids a dangling IDREF; supports trigger-only/navigation use).
571
+ */ const controlsPanel = active && hasPanel(value);
540
572
  return /*#__PURE__*/ jsx("button", {
541
573
  ...rest,
542
574
  ref: buttonRef,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@versini/ui-tabs",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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": "workspace:*",
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": "7be5c9c2698fa937dde9eb2f99484a37e9743c81"
51
52
  }