@versini/ui-tabs 1.1.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/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @versini/ui-tabs
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@versini/ui-tabs?style=flat-square)](https://www.npmjs.com/package/@versini/ui-tabs)
4
+ ![npm package minimized gzipped size](<https://img.shields.io/bundlejs/size/%40versini%2Fui-tabs?style=flat-square&label=size%20(gzip)>)
5
+
6
+ > An accessible, zero-layout-shift React tabs component built with TypeScript and TailwindCSS.
7
+
8
+ The Tabs component provides a fully WAI-ARIA-compliant tab set: keyboard navigation, roving tabindex, automatic/manual activation, horizontal/vertical orientation, and a no-layout-shift active state (the active tab can go bold without nudging its neighbors). It is hand-rolled with no third-party runtime dependency to keep the bundle small.
9
+
10
+ ## Table of Contents
11
+
12
+ - [Features](#features)
13
+ - [Installation](#installation)
14
+ - [Usage](#usage)
15
+ - [API](#api)
16
+ - [Accessibility](#accessibility)
17
+
18
+ ## Features
19
+
20
+ - **โ™ฟ Fully accessible**: `tablist` / `tab` / `tabpanel` roles, `aria-selected`, `aria-controls`/`aria-labelledby`, roving tabindex, arrow / Home / End keys, automatic or manual activation.
21
+ - **๐Ÿ“ No layout shift**: the active tab goes bold without shifting surrounding tabs (an invisible bold twin reserves the width).
22
+ - **๐Ÿงญ Orientation**: `horizontal` (default) or `vertical`.
23
+ - **๐ŸŽจ Surface-aware theming**: `mode` (`system` | `light` | `dark` | `alt-system`) keeps text readable even on a surface whose lightness differs from the ambient theme.
24
+ - **๐ŸŽ›๏ธ Controlled & uncontrolled**: `value` / `onValueChange` or `defaultValue`.
25
+ - **๐Ÿชถ Tiny**: no Radix, only `clsx` + three small `@versini/ui-hooks`.
26
+ - **๐Ÿงช Type safe**: generic over the tab value (`Tabs<Value extends string>`), so your tab keys stay narrowed.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install @versini/ui-tabs
32
+ ```
33
+
34
+ > **Note**: This component requires TailwindCSS and the `@versini/ui-styles` plugin for proper styling. See the [installation documentation](https://versini-org.github.io/ui-components/?path=/docs/getting-started-installation--docs) for complete setup instructions.
35
+
36
+ ## Usage
37
+
38
+ ```tsx
39
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@versini/ui-tabs";
40
+
41
+ type TabKey = "details" | "history" | "settings";
42
+
43
+ export const Example = () => (
44
+ <Tabs<TabKey> defaultValue="details">
45
+ <TabsList aria-label="Parcel views">
46
+ <TabsTrigger value="details">Details</TabsTrigger>
47
+ <TabsTrigger value="history">History</TabsTrigger>
48
+ <TabsTrigger value="settings">Settings</TabsTrigger>
49
+ </TabsList>
50
+ <TabsContent value="details">Details panel</TabsContent>
51
+ <TabsContent value="history">History panel</TabsContent>
52
+ <TabsContent value="settings">Settings panel</TabsContent>
53
+ </Tabs>
54
+ );
55
+ ```
56
+
57
+ ### Controlled
58
+
59
+ ```tsx
60
+ const [value, setValue] = useState<TabKey>("details");
61
+
62
+ <Tabs<TabKey> value={value} onValueChange={setValue}>
63
+ {/* ... */}
64
+ </Tabs>;
65
+ ```
66
+
67
+ > **Note**: In uncontrolled mode with no `defaultValue`, the first enabled tab is auto-selected after mount and `onValueChange` fires once to report it. Pass `defaultValue` (or use controlled `value`) to avoid the mount-time callback.
68
+
69
+ ### Navigation / trigger-only
70
+
71
+ `TabsList` + `TabsTrigger` work without any `TabsContent` (e.g. route-driven tabs). In that mode no `aria-controls` is emitted, so it stays valid ARIA. Keep your panel content in your own switch when it needs its own state or lazy loading; only the active `TabsContent` is mounted, so panels with unsaved local state should not live inside `TabsContent` (a `forceMount` opt-in is planned).
72
+
73
+ ## API
74
+
75
+ ### `Tabs<Value extends string = string>`
76
+
77
+ | Prop | Type | Default |
78
+ |------------------|----------------------------------------|------------------|
79
+ | `value` | `Value` | (controlled) |
80
+ | `defaultValue` | `Value` | first enabled trigger |
81
+ | `onValueChange` | `(value: Value) => void` | โ€” |
82
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |
83
+ | `activationMode` | `"automatic" \| "manual"` | `"automatic"` |
84
+ | `size` | `"small" \| "medium" \| "large"` | `"medium"` |
85
+ | `mode` | `"system" \| "light" \| "dark" \| "alt-system"` | `"system"` |
86
+ | `className` | `string` | โ€” |
87
+
88
+ ### `TabsList`
89
+
90
+ `role="tablist"`. Owns keyboard navigation. Pass `aria-label` or `aria-labelledby` to give the tablist an accessible name (required by APG; a dev warning fires if missing). Extra props are forwarded.
91
+
92
+ ### `TabsTrigger<Value extends string = string>`
93
+
94
+ `role="tab"`. Props: `value` (required, unique within a `Tabs`), `disabled`, plus standard button attributes. Labels may be any non-interactive node (text, icon + text); the no-shift twin duplicates the label, so labels must not contain focusable or `id`-bearing elements.
95
+
96
+ ### `TabsContent<Value extends string = string>`
97
+
98
+ `role="tabpanel"`. Props: `value` (required), plus standard div attributes. Renders only when active and a matching trigger exists.
99
+
100
+ ## Accessibility
101
+
102
+ Implements the [WAI-ARIA APG Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/):
103
+
104
+ - Roving tabindex: exactly one trigger is in the tab order; arrow keys move between tabs (wrapping, skipping disabled), Home/End jump to first/last.
105
+ - `automatic` activation selects on focus; `manual` requires Enter/Space.
106
+ - Activation keeps focus on the trigger; `Tab` moves to the active panel.
107
+ - `aria-controls` is emitted only on the active trigger (whose panel is mounted), avoiding dangling references. APG's example wires it on every tab; this component intentionally deviates because inactive panels are unmounted.
108
+ - The active panel is `tabIndex={0}`.
109
+
110
+ ### Theming and `mode`
111
+
112
+ Text color must match the **surface** the tabs sit on, which is not always the
113
+ ambient theme. Because dark mode is driven by `prefers-color-scheme`, the `dark:`
114
+ variant follows the OS, not the local surface โ€” so a light card inside a
115
+ dark-mode app would otherwise get light text on a light background (unreadable).
116
+
117
+ - `mode="system"` (default) โ€” follow the ambient theme. Use when the tabs sit on
118
+ the page surface (which follows the OS).
119
+ - `mode="light"` โ€” force dark text. Use on a **light** surface regardless of OS.
120
+ - `mode="dark"` โ€” force light text. Use on a **dark** surface regardless of OS.
121
+ - `mode="alt-system"` โ€” invert the ambient theme.
122
+
123
+ ```tsx
124
+ // A light card that stays light even when the OS is in dark mode:
125
+ <div className="bg-surface-light">
126
+ <Tabs defaultValue="a" mode="light">โ€ฆ</Tabs>
127
+ </div>
128
+ ```
@@ -0,0 +1,160 @@
1
+ import { JSX } from 'react/jsx-runtime';
2
+
3
+ export declare type ActivationMode = "automatic" | "manual";
4
+
5
+ export declare type Mode = "dark" | "light" | "system" | "alt-system";
6
+
7
+ export declare type Orientation = "horizontal" | "vertical";
8
+
9
+ export declare type Size = "small" | "medium" | "large";
10
+
11
+ /**
12
+ * Tabs root. Owns the active value (controlled or uncontrolled), the
13
+ * trigger/panel registries, and the shared context.
14
+ *
15
+ * Invariants (referenced by name in the code comments and the test suite):
16
+ * 1. Unique values โ€” trigger `value`s must be unique within a Tabs; duplicates
17
+ * are unsupported and dev-warn (no runtime de-dup).
18
+ * 2. Exactly one tabbable trigger โ€” `tabIndex=0` goes to the active trigger if
19
+ * it matches one (even when disabled, see 5), else the first enabled, else
20
+ * the first; all others get `tabIndex=-1`.
21
+ * 3. Default resolution โ€” uncontrolled with no `defaultValue` activates the
22
+ * first non-disabled trigger after registration, via a layout effect.
23
+ * 4. No-match active value โ€” if the active value matches no trigger, no panel
24
+ * renders; invariant 2 still keeps the tablist keyboard-reachable.
25
+ * 5. Disabled-but-active โ€” selection is independent of `disabled`; a disabled
26
+ * trigger that is the active value stays selected and tabbable.
27
+ * 6. All disabled โ€” arrow/Home/End are no-ops; the first trigger stays tabbable.
28
+ * 7. Dynamic add/remove โ€” in uncontrolled mode, removing/disabling the active
29
+ * trigger recovers to the nearest enabled trigger (next, else previous), and
30
+ * focus follows if it was inside the tablist.
31
+ * 8. Orphans โ€” an orphan TabsContent (no matching trigger) never renders; an
32
+ * orphan TabsTrigger (no matching panel) renders without `aria-controls`.
33
+ * 9. DOM-order traversal โ€” triggers register in DOM order (compareDocumentPosition)
34
+ * so keyboard navigation matches visual order.
35
+ */
36
+ export declare function Tabs<Value extends string = string>({ children, value, defaultValue, onValueChange, orientation, activationMode, size, mode, className, }: TabsProps<Value>): JSX.Element;
37
+
38
+ export declare namespace Tabs {
39
+ var displayName: string;
40
+ }
41
+
42
+ export declare const TABS_CLASSNAME = "av-tabs";
43
+
44
+ export declare const TABS_CONTENT_CLASSNAME = "av-tabs-content";
45
+
46
+ export declare const TABS_LIST_CLASSNAME = "av-tabs-list";
47
+
48
+ export declare const TABS_TRIGGER_CLASSNAME = "av-tabs-trigger";
49
+
50
+ export declare function TabsContent<Value extends string = string>({ value, children, className, ...rest }: TabsContentProps<Value>): JSX.Element | null;
51
+
52
+ export declare namespace TabsContent {
53
+ var displayName: string;
54
+ }
55
+
56
+ export declare type TabsContentProps<Value extends string = string> = {
57
+ /**
58
+ * The value of the trigger this panel belongs to.
59
+ */
60
+ value: Value;
61
+ /**
62
+ * The panel content.
63
+ */
64
+ children: React.ReactNode;
65
+ /**
66
+ * The classes to apply to the panel element.
67
+ */
68
+ className?: string;
69
+ } & React.HTMLAttributes<HTMLDivElement>;
70
+
71
+ export declare const TabsList: {
72
+ ({ children, className, onKeyDown, ...rest }: TabsListProps): JSX.Element;
73
+ displayName: string;
74
+ };
75
+
76
+ export declare type TabsListProps = {
77
+ /**
78
+ * The TabsTrigger children.
79
+ */
80
+ children: React.ReactNode;
81
+ /**
82
+ * The classes to apply to the tablist element.
83
+ */
84
+ className?: string;
85
+ } & React.HTMLAttributes<HTMLDivElement>;
86
+
87
+ export declare type TabsProps<Value extends string = string> = {
88
+ /**
89
+ * The TabsList and TabsContent children.
90
+ */
91
+ children: React.ReactNode;
92
+ /**
93
+ * The controlled active tab value.
94
+ */
95
+ value?: Value;
96
+ /**
97
+ * The initially active tab value (uncontrolled). When omitted, the first
98
+ * non-disabled trigger becomes active after mount.
99
+ */
100
+ defaultValue?: Value;
101
+ /**
102
+ * Callback fired when the active tab changes.
103
+ */
104
+ onValueChange?: (value: Value) => void;
105
+ /**
106
+ * The orientation of the tab list.
107
+ * @default "horizontal"
108
+ */
109
+ orientation?: Orientation;
110
+ /**
111
+ * Whether tabs activate on focus ("automatic") or require Enter/Space
112
+ * ("manual").
113
+ * @default "automatic"
114
+ */
115
+ activationMode?: ActivationMode;
116
+ /**
117
+ * The size of the triggers.
118
+ * @default "medium"
119
+ */
120
+ size?: Size;
121
+ /**
122
+ * 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,
125
+ * `"alt-system"` inverts it.
126
+ * @default "system"
127
+ */
128
+ mode?: Mode;
129
+ /**
130
+ * The classes to apply to the root element.
131
+ */
132
+ className?: string;
133
+ };
134
+
135
+ export declare function TabsTrigger<Value extends string = string>({ value, children, disabled, className, onClick, ...rest }: TabsTriggerProps<Value>): JSX.Element;
136
+
137
+ export declare namespace TabsTrigger {
138
+ var displayName: string;
139
+ }
140
+
141
+ export declare type TabsTriggerProps<Value extends string = string> = {
142
+ /**
143
+ * The unique value identifying this tab. Must be unique within a Tabs.
144
+ */
145
+ value: Value;
146
+ /**
147
+ * The tab label (text, icon + text, any non-interactive node).
148
+ */
149
+ children: React.ReactNode;
150
+ /**
151
+ * Whether the trigger is disabled (excluded from keyboard traversal).
152
+ */
153
+ disabled?: boolean;
154
+ /**
155
+ * The classes to apply to the trigger button.
156
+ */
157
+ className?: string;
158
+ } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value">;
159
+
160
+ export { }
@@ -0,0 +1,585 @@
1
+ /*!
2
+ @versini/ui-tabs v1.1.0
3
+ ยฉ 2026 gizmette.com
4
+ */
5
+
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ import { useUncontrolled } from "@versini/ui-hooks/use-uncontrolled";
8
+ import { useUniqueId } from "@versini/ui-hooks/use-unique-id";
9
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
10
+ import clsx from "clsx";
11
+
12
+ const TABS_CLASSNAME = "av-tabs";
13
+ const TABS_LIST_CLASSNAME = "av-tabs-list";
14
+ const TABS_TRIGGER_CLASSNAME = "av-tabs-trigger";
15
+ const TABS_CONTENT_CLASSNAME = "av-tabs-content";
16
+
17
+
18
+
19
+
20
+
21
+
22
+ /* v8 ignore start - default context values are fallbacks, never used directly */ const TabsContext = createContext({
23
+ value: undefined,
24
+ tabbableValue: undefined,
25
+ setValue: ()=>{},
26
+ orientation: "horizontal",
27
+ activationMode: "automatic",
28
+ size: "medium",
29
+ mode: "system",
30
+ baseId: "",
31
+ registerTrigger: ()=>{},
32
+ unregisterTrigger: ()=>{},
33
+ getTriggers: ()=>[],
34
+ registerPanel: ()=>{},
35
+ unregisterPanel: ()=>{},
36
+ hasPanel: ()=>false
37
+ }); /* v8 ignore stop */
38
+
39
+
40
+ /**
41
+ * `useLayoutEffect` in the browser, `useEffect` on the server โ€” avoids React's
42
+ * "useLayoutEffect does nothing on the server" warning during SSR while keeping
43
+ * the synchronous, pre-paint timing in the browser.
44
+ */ /* v8 ignore start - useEffect fallback only runs during SSR (window undefined) */ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; /* v8 ignore stop */
45
+
46
+
47
+
48
+
49
+ /**
50
+ * Deterministic, SSR-safe ids linking a trigger to its panel. The trigger's
51
+ * `aria-controls` and the panel's `id` (and the panel's `aria-labelledby` and
52
+ * the trigger's `id`) MUST match, so both components derive ids through these
53
+ * helpers rather than inlining the template โ€” divergence would silently break
54
+ * the accessibility wiring.
55
+ */ const getTabId = (baseId, value)=>`${baseId}-tab-${value}`;
56
+ const getPanelId = (baseId, value)=>`${baseId}-panel-${value}`;
57
+ /**
58
+ * Theme-aware text color. `light`/`dark` force the color for a surface whose
59
+ * lightness differs from the ambient theme (e.g. a light card in dark mode);
60
+ * `system` follows the ambient theme, `alt-system` inverts it.
61
+ */ const getModeTextClasses = ({ mode })=>{
62
+ return clsx({
63
+ "text-copy-dark": mode === "light",
64
+ "text-copy-light": mode === "dark",
65
+ "text-copy-dark dark:text-copy-light": mode === "system",
66
+ "text-copy-light dark:text-copy-dark": mode === "alt-system"
67
+ });
68
+ };
69
+ const getTabsClasses = ({ className, orientation })=>{
70
+ return clsx(TABS_CLASSNAME, orientation === "vertical" ? "flex flex-row gap-4" : "flex flex-col gap-2", className);
71
+ };
72
+ const getTabsListClasses = ({ orientation })=>{
73
+ return clsx(TABS_LIST_CLASSNAME, "flex", orientation === "vertical" ? "flex-col border-l border-border-medium" : "flex-row gap-2 border-b border-border-medium");
74
+ };
75
+ const getTabsTriggerSizeClasses = ({ size })=>{
76
+ return clsx({
77
+ "text-xs px-2 py-1": size === "small",
78
+ "text-sm px-4 py-2": size === "medium",
79
+ "text-base px-5 py-2.5": size === "large"
80
+ });
81
+ };
82
+ const getTabsTriggerClasses = ({ size, orientation, active, mode })=>{
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", // 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
+ mode
89
+ }), "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus-dark", "disabled:cursor-not-allowed disabled:opacity-50", getTabsTriggerSizeClasses({
90
+ size
91
+ }), borderSide, active ? "border-action-light" : "border-transparent");
92
+ };
93
+ 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({
97
+ mode
98
+ }), "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus-dark", className);
99
+ };
100
+
101
+
102
+
103
+
104
+
105
+
106
+
107
+
108
+ /** Nearest enabled trigger to `fromIndex`: scan forward (next), then backward (previous). */ const findNearestEnabledIndex = (triggers, fromIndex)=>{
109
+ const count = triggers.length;
110
+ const start = Math.min(Math.max(fromIndex, 0), count);
111
+ for(let i = start; i < count; i++){
112
+ if (!triggers[i].disabled) {
113
+ return i;
114
+ }
115
+ }
116
+ for(let i = start - 1; i >= 0; i--){
117
+ if (!triggers[i].disabled) {
118
+ return i;
119
+ }
120
+ }
121
+ return -1;
122
+ };
123
+ /**
124
+ * Tabs root. Owns the active value (controlled or uncontrolled), the
125
+ * trigger/panel registries, and the shared context.
126
+ *
127
+ * Invariants (referenced by name in the code comments and the test suite):
128
+ * 1. Unique values โ€” trigger `value`s must be unique within a Tabs; duplicates
129
+ * are unsupported and dev-warn (no runtime de-dup).
130
+ * 2. Exactly one tabbable trigger โ€” `tabIndex=0` goes to the active trigger if
131
+ * it matches one (even when disabled, see 5), else the first enabled, else
132
+ * the first; all others get `tabIndex=-1`.
133
+ * 3. Default resolution โ€” uncontrolled with no `defaultValue` activates the
134
+ * first non-disabled trigger after registration, via a layout effect.
135
+ * 4. No-match active value โ€” if the active value matches no trigger, no panel
136
+ * renders; invariant 2 still keeps the tablist keyboard-reachable.
137
+ * 5. Disabled-but-active โ€” selection is independent of `disabled`; a disabled
138
+ * trigger that is the active value stays selected and tabbable.
139
+ * 6. All disabled โ€” arrow/Home/End are no-ops; the first trigger stays tabbable.
140
+ * 7. Dynamic add/remove โ€” in uncontrolled mode, removing/disabling the active
141
+ * trigger recovers to the nearest enabled trigger (next, else previous), and
142
+ * focus follows if it was inside the tablist.
143
+ * 8. Orphans โ€” an orphan TabsContent (no matching trigger) never renders; an
144
+ * orphan TabsTrigger (no matching panel) renders without `aria-controls`.
145
+ * 9. DOM-order traversal โ€” triggers register in DOM order (compareDocumentPosition)
146
+ * so keyboard navigation matches visual order.
147
+ */ function Tabs({ children, value, defaultValue, onValueChange, orientation = "horizontal", activationMode = "automatic", size = "medium", mode = "system", className }) {
148
+ const [activeValue, setActiveValue] = useUncontrolled({
149
+ value,
150
+ defaultValue,
151
+ onChange: onValueChange
152
+ });
153
+ const baseId = useUniqueId("av-tabs-");
154
+ const triggersRef = useRef([]);
155
+ const panelsRef = useRef(new Set());
156
+ 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);
165
+ 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);
170
+ setActiveRef.current = setActiveValue;
171
+ const setValue = useCallback((next)=>{
172
+ setActiveRef.current(next);
173
+ }, []);
174
+ const getTriggers = useCallback(()=>triggersRef.current, []);
175
+ const registerTrigger = useCallback((registration)=>{
176
+ const existingIndex = triggersRef.current.findIndex((trigger)=>trigger.element === registration.element);
177
+ /* v8 ignore start - re-registration of the same element on re-render */ if (existingIndex >= 0) {
178
+ triggersRef.current[existingIndex] = registration;
179
+ bumpVersion();
180
+ return;
181
+ }
182
+ /* v8 ignore stop */ if (triggersRef.current.some((trigger)=>trigger.value === registration.value)) {
183
+ console.warn(`[ui-tabs] Duplicate TabsTrigger value "${registration.value}". Trigger values must be unique within a Tabs.`);
184
+ }
185
+ // Insert in DOM order so keyboard traversal matches visual order.
186
+ const insertIndex = triggersRef.current.findIndex((trigger)=>trigger.element.compareDocumentPosition(registration.element) & Node.DOCUMENT_POSITION_PRECEDING);
187
+ if (insertIndex !== -1) {
188
+ triggersRef.current.splice(insertIndex, 0, registration);
189
+ } else {
190
+ triggersRef.current.push(registration);
191
+ }
192
+ bumpVersion();
193
+ }, [
194
+ bumpVersion
195
+ ]);
196
+ const unregisterTrigger = useCallback((element)=>{
197
+ triggersRef.current = triggersRef.current.filter((trigger)=>trigger.element !== element);
198
+ bumpVersion();
199
+ }, [
200
+ bumpVersion
201
+ ]);
202
+ const registerPanel = useCallback((panelValue)=>{
203
+ panelsRef.current.add(panelValue);
204
+ bumpVersion();
205
+ }, [
206
+ bumpVersion
207
+ ]);
208
+ const unregisterPanel = useCallback((panelValue)=>{
209
+ panelsRef.current.delete(panelValue);
210
+ bumpVersion();
211
+ }, [
212
+ bumpVersion
213
+ ]);
214
+ const hasPanel = useCallback((panelValue)=>panelsRef.current.has(panelValue), []);
215
+ const handleRootFocus = useCallback(()=>{
216
+ focusWithinRef.current = true;
217
+ }, []);
218
+ 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)) {
223
+ focusWithinRef.current = false;
224
+ }
225
+ }, []);
226
+ // Default resolution (Invariant 3) + dynamic recovery (Invariant 7), both
227
+ // uncontrolled-only (controlled mode delegates recovery to the consumer).
228
+ useIsomorphicLayoutEffect(()=>{
229
+ if (value !== undefined) {
230
+ return;
231
+ }
232
+ const triggers = triggersRef.current;
233
+ const activeIsValid = activeValue !== undefined && triggers.some((trigger)=>trigger.value === activeValue && !trigger.disabled);
234
+ if (activeIsValid) {
235
+ return;
236
+ }
237
+ const recoveredIndex = findNearestEnabledIndex(triggers, lastActiveIndexRef.current);
238
+ if (recoveredIndex !== -1) {
239
+ const recovered = triggers[recoveredIndex];
240
+ 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) {
244
+ recovered.element.focus();
245
+ }
246
+ }
247
+ });
248
+ const triggers = triggersRef.current;
249
+ const activeIndex = triggers.findIndex((trigger)=>trigger.value === activeValue);
250
+ if (activeIndex !== -1) {
251
+ lastActiveIndexRef.current = activeIndex;
252
+ }
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;
257
+ if (activeIndex !== -1) {
258
+ tabbableValue = activeValue;
259
+ } else {
260
+ tabbableValue = (triggers.find((trigger)=>!trigger.disabled) ?? triggers[0])?.value;
261
+ }
262
+ // biome-ignore lint/correctness/useExhaustiveDependencies: registrationVersion is an intentional tripwire. The memo reads the mutable trigger/panel registries (refs the linter cannot see), so it MUST recompute on every (un)registration; removing this dep reintroduces stale/dangling aria-controls.
263
+ const contextValue = useMemo(()=>({
264
+ value: activeValue,
265
+ tabbableValue,
266
+ setValue,
267
+ orientation,
268
+ activationMode,
269
+ size,
270
+ mode,
271
+ baseId,
272
+ registerTrigger,
273
+ unregisterTrigger,
274
+ getTriggers,
275
+ registerPanel,
276
+ unregisterPanel,
277
+ hasPanel
278
+ }), [
279
+ activeValue,
280
+ tabbableValue,
281
+ // registrationVersion drives recompute of roving tabIndex / aria-controls
282
+ // from the registries when a trigger/panel (un)registers.
283
+ registrationVersion,
284
+ setValue,
285
+ orientation,
286
+ activationMode,
287
+ size,
288
+ mode,
289
+ baseId,
290
+ registerTrigger,
291
+ unregisterTrigger,
292
+ getTriggers,
293
+ registerPanel,
294
+ unregisterPanel,
295
+ hasPanel
296
+ ]);
297
+ return /*#__PURE__*/ jsx(TabsContext.Provider, {
298
+ value: contextValue,
299
+ children: /*#__PURE__*/ jsx("div", {
300
+ className: getTabsClasses({
301
+ className,
302
+ orientation
303
+ }),
304
+ onFocus: handleRootFocus,
305
+ onBlur: handleRootBlur,
306
+ children: children
307
+ })
308
+ });
309
+ }
310
+ Tabs.displayName = "Tabs";
311
+
312
+
313
+
314
+
315
+
316
+
317
+ function TabsContent({ value, children, className, ...rest }) {
318
+ const { value: activeValue, mode, baseId, registerPanel, unregisterPanel, getTriggers } = useContext(TabsContext);
319
+ useIsomorphicLayoutEffect(()=>{
320
+ registerPanel(value);
321
+ return ()=>{
322
+ unregisterPanel(value);
323
+ };
324
+ }, [
325
+ value,
326
+ registerPanel,
327
+ unregisterPanel
328
+ ]);
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) {
333
+ return null;
334
+ }
335
+ const hasMatchingTrigger = getTriggers().some((trigger)=>trigger.value === value);
336
+ if (!hasMatchingTrigger) {
337
+ return null;
338
+ }
339
+ const tabId = getTabId(baseId, value);
340
+ const panelId = getPanelId(baseId, value);
341
+ return /*#__PURE__*/ jsx("div", {
342
+ ...rest,
343
+ role: "tabpanel",
344
+ id: panelId,
345
+ "aria-labelledby": tabId,
346
+ tabIndex: 0,
347
+ className: getTabsContentClasses({
348
+ className,
349
+ mode
350
+ }),
351
+ children: children
352
+ });
353
+ }
354
+ TabsContent.displayName = "TabsContent";
355
+
356
+
357
+ const getNextEnabledIndex = (triggers, currentIndex, direction)=>{
358
+ 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;
362
+ for(let i = 0; i < count; i++){
363
+ index = (index + direction + count) % count;
364
+ if (!triggers[index].disabled) {
365
+ return index;
366
+ }
367
+ }
368
+ return -1;
369
+ };
370
+ const getFirstEnabledIndex = (triggers)=>{
371
+ for(let i = 0; i < triggers.length; i++){
372
+ if (!triggers[i].disabled) {
373
+ return i;
374
+ }
375
+ }
376
+ return -1;
377
+ };
378
+ const getLastEnabledIndex = (triggers)=>{
379
+ for(let i = triggers.length - 1; i >= 0; i--){
380
+ if (!triggers[i].disabled) {
381
+ return i;
382
+ }
383
+ }
384
+ return -1;
385
+ };
386
+ /**
387
+ * Returns an onKeyDown handler for the tablist implementing the WAI-ARIA tabs
388
+ * keyboard pattern: arrow keys (per orientation) with wrap + disabled-skip,
389
+ * Home/End, and Enter/Space activation in manual mode. Off-axis arrows are not
390
+ * intercepted so native behavior (page scroll) is preserved.
391
+ */ const useTabsKeyboard = ({ getTriggers, orientation, activationMode, value, setValue })=>{
392
+ return useCallback((event)=>{
393
+ const triggers = getTriggers();
394
+ if (triggers.length === 0) {
395
+ return;
396
+ }
397
+ const nextKey = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
398
+ const prevKey = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
399
+ const focusedIndex = triggers.findIndex((trigger)=>trigger.element === document.activeElement);
400
+ const currentIndex = focusedIndex >= 0 ? focusedIndex : triggers.findIndex((trigger)=>trigger.value === value);
401
+ const moveTo = (index)=>{
402
+ if (index < 0) {
403
+ return;
404
+ }
405
+ const target = triggers[index];
406
+ target.element.focus();
407
+ if (activationMode === "automatic") {
408
+ setValue(target.value);
409
+ }
410
+ };
411
+ switch(event.key){
412
+ case nextKey:
413
+ {
414
+ event.preventDefault();
415
+ moveTo(getNextEnabledIndex(triggers, currentIndex, 1));
416
+ break;
417
+ }
418
+ case prevKey:
419
+ {
420
+ event.preventDefault();
421
+ moveTo(getNextEnabledIndex(triggers, currentIndex, -1));
422
+ break;
423
+ }
424
+ case "Home":
425
+ {
426
+ event.preventDefault();
427
+ moveTo(getFirstEnabledIndex(triggers));
428
+ break;
429
+ }
430
+ case "End":
431
+ {
432
+ event.preventDefault();
433
+ moveTo(getLastEnabledIndex(triggers));
434
+ break;
435
+ }
436
+ case "Enter":
437
+ case " ":
438
+ {
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();
443
+ if (activationMode === "manual" && currentIndex >= 0 && !triggers[currentIndex].disabled) {
444
+ setValue(triggers[currentIndex].value);
445
+ }
446
+ break;
447
+ }
448
+ default:
449
+ break;
450
+ }
451
+ }, [
452
+ getTriggers,
453
+ orientation,
454
+ activationMode,
455
+ value,
456
+ setValue
457
+ ]);
458
+ };
459
+
460
+
461
+
462
+
463
+
464
+
465
+
466
+ const TabsList = ({ children, className, onKeyDown, ...rest })=>{
467
+ const { orientation, activationMode, value, setValue, getTriggers } = useContext(TabsContext);
468
+ const listRef = useRef(null);
469
+ const handleKeyDown = useTabsKeyboard({
470
+ getTriggers,
471
+ orientation,
472
+ activationMode,
473
+ value,
474
+ setValue
475
+ });
476
+ // Dev: warn once if the tablist has no accessible name (APG requirement).
477
+ useEffect(()=>{
478
+ const list = listRef.current;
479
+ /* v8 ignore start - ref is always set after mount */ if (!list) {
480
+ return;
481
+ }
482
+ /* v8 ignore stop */ const hasName = list.hasAttribute("aria-label") || list.hasAttribute("aria-labelledby");
483
+ if (!hasName) {
484
+ console.warn("[ui-tabs] TabsList should have an accessible name via `aria-label` or `aria-labelledby`.");
485
+ }
486
+ }, []);
487
+ return /*#__PURE__*/ jsx("div", {
488
+ ...rest,
489
+ ref: listRef,
490
+ role: "tablist",
491
+ "aria-orientation": orientation,
492
+ className: clsx(getTabsListClasses({
493
+ orientation
494
+ }), className),
495
+ onKeyDown: (event)=>{
496
+ onKeyDown?.(event);
497
+ handleKeyDown(event);
498
+ },
499
+ children: children
500
+ });
501
+ };
502
+ TabsList.displayName = "TabsList";
503
+
504
+
505
+
506
+
507
+
508
+
509
+
510
+ function TabsTrigger({ value, children, disabled = false, className, onClick, ...rest }) {
511
+ const { value: activeValue, tabbableValue, setValue, orientation, size, mode, baseId, registerTrigger, unregisterTrigger, hasPanel } = useContext(TabsContext);
512
+ 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(()=>{
516
+ const element = buttonRef.current;
517
+ /* v8 ignore start - ref is always set after mount */ if (!element) {
518
+ return;
519
+ }
520
+ /* v8 ignore stop */ registerTrigger({
521
+ element,
522
+ value,
523
+ disabled
524
+ });
525
+ return ()=>{
526
+ unregisterTrigger(element);
527
+ };
528
+ }, [
529
+ value,
530
+ disabled,
531
+ registerTrigger,
532
+ unregisterTrigger
533
+ ]);
534
+ const active = activeValue === value;
535
+ const tabId = getTabId(baseId, value);
536
+ 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);
540
+ return /*#__PURE__*/ jsx("button", {
541
+ ...rest,
542
+ ref: buttonRef,
543
+ type: "button",
544
+ role: "tab",
545
+ id: tabId,
546
+ "aria-selected": active,
547
+ "aria-controls": controlsPanel ? panelId : undefined,
548
+ tabIndex: tabbableValue === value ? 0 : -1,
549
+ disabled: disabled,
550
+ className: clsx(getTabsTriggerClasses({
551
+ size,
552
+ orientation,
553
+ active,
554
+ mode
555
+ }), className),
556
+ onClick: (event)=>{
557
+ onClick?.(event);
558
+ setValue(value);
559
+ },
560
+ children: /*#__PURE__*/ jsxs("span", {
561
+ className: "grid",
562
+ children: [
563
+ /*#__PURE__*/ jsx("span", {
564
+ "aria-hidden": "true",
565
+ className: "invisible font-semibold [grid-area:1/1]",
566
+ children: children
567
+ }),
568
+ /*#__PURE__*/ jsx("span", {
569
+ "data-active": active,
570
+ className: "[grid-area:1/1] data-[active=true]:font-semibold",
571
+ children: children
572
+ })
573
+ ]
574
+ })
575
+ });
576
+ }
577
+ TabsTrigger.displayName = "TabsTrigger";
578
+
579
+
580
+
581
+
582
+
583
+
584
+
585
+ export { TABS_CLASSNAME, TABS_CONTENT_CLASSNAME, TABS_LIST_CLASSNAME, TABS_TRIGGER_CLASSNAME, Tabs, TabsContent, TabsList, TabsTrigger };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@versini/ui-tabs",
3
+ "version": "1.1.0",
4
+ "license": "MIT",
5
+ "author": "Arno Versini",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "homepage": "https://www.npmjs.com/package/@versini/ui-tabs",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git@github.com:aversini/ui-components.git"
13
+ },
14
+ "type": "module",
15
+ "main": "dist/components/index.js",
16
+ "types": "dist/components/index.d.ts",
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build:check": "tsc",
23
+ "build:js": "rslib build",
24
+ "build:types": "echo 'Types now built with rslib'",
25
+ "build": "npm-run-all --serial clean build:check build:js",
26
+ "clean": "rimraf dist tmp coverage",
27
+ "dev:js": "rslib build --watch",
28
+ "dev:types": "echo 'Types now watched with rslib'",
29
+ "dev": "rslib build --watch",
30
+ "lint": "biome lint src",
31
+ "lint:fix": "biome check src --write --no-errors-on-unmatched",
32
+ "prettier": "biome check --write --no-errors-on-unmatched",
33
+ "start": "static-server dist --port 5173",
34
+ "test:coverage:ui": "vitest --coverage --ui",
35
+ "test:coverage": "vitest run --coverage",
36
+ "test:watch": "vitest",
37
+ "test": "vitest run",
38
+ "test:visual": "playwright test -c playwright-ct.config.ts",
39
+ "test:visual:report": "playwright show-report playwright-report",
40
+ "test:visual:update": "playwright test -c playwright-ct.config.ts --update-snapshots",
41
+ "test:visual:ui": "playwright test -c playwright-ct.config.ts --ui"
42
+ },
43
+ "dependencies": {
44
+ "@versini/ui-hooks": "workspace:*",
45
+ "clsx": "2.1.1",
46
+ "tailwindcss": "4.3.0"
47
+ },
48
+ "sideEffects": [
49
+ "**/*.css"
50
+ ]
51
+ }