@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 +128 -0
- package/dist/components/index.d.ts +160 -0
- package/dist/components/index.js +585 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# @versini/ui-tabs
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@versini/ui-tabs)
|
|
4
|
+
>)
|
|
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
|
+
}
|