@discourser/design-system 0.3.1 → 0.5.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 +12 -4
- package/dist/styles.css +5126 -0
- package/guidelines/Guidelines.md +92 -41
- package/guidelines/components/accordion.md +732 -0
- package/guidelines/components/avatar.md +1015 -0
- package/guidelines/components/badge.md +728 -0
- package/guidelines/components/button.md +75 -40
- package/guidelines/components/card.md +84 -25
- package/guidelines/components/checkbox.md +671 -0
- package/guidelines/components/dialog.md +619 -31
- package/guidelines/components/drawer.md +1616 -0
- package/guidelines/components/heading.md +576 -0
- package/guidelines/components/icon-button.md +92 -37
- package/guidelines/components/input-addon.md +685 -0
- package/guidelines/components/input-group.md +830 -0
- package/guidelines/components/input.md +92 -37
- package/guidelines/components/popover.md +1271 -0
- package/guidelines/components/progress.md +836 -0
- package/guidelines/components/radio-group.md +852 -0
- package/guidelines/components/select.md +1662 -0
- package/guidelines/components/skeleton.md +802 -0
- package/guidelines/components/slider.md +911 -0
- package/guidelines/components/spinner.md +783 -0
- package/guidelines/components/switch.md +105 -38
- package/guidelines/components/tabs.md +1488 -0
- package/guidelines/components/textarea.md +495 -0
- package/guidelines/components/toast.md +784 -0
- package/guidelines/components/tooltip.md +912 -0
- package/guidelines/design-tokens/colors.md +309 -72
- package/guidelines/design-tokens/elevation.md +615 -45
- package/guidelines/design-tokens/spacing.md +654 -74
- package/guidelines/design-tokens/typography.md +432 -50
- package/guidelines/overview-components.md +60 -8
- package/guidelines/overview-imports.md +314 -0
- package/guidelines/overview-patterns.md +3852 -0
- package/package.json +4 -2
|
@@ -0,0 +1,1488 @@
|
|
|
1
|
+
# Tabs
|
|
2
|
+
|
|
3
|
+
**Purpose:** Navigation component for organizing content into separate views with smooth transitions and visual indicators, following Material Design 3 principles.
|
|
4
|
+
|
|
5
|
+
## When to Use This Component
|
|
6
|
+
|
|
7
|
+
Use Tabs when you need to **organize related content into separate, mutually exclusive views** where users switch between them without leaving the page.
|
|
8
|
+
|
|
9
|
+
### Decision Tree
|
|
10
|
+
|
|
11
|
+
| Scenario | Use Tabs? | Alternative | Reasoning |
|
|
12
|
+
| ---------------------------------------------------- | --------- | ------------------------ | ------------------------------------------------------------ |
|
|
13
|
+
| Switching between related views (Dashboard sections) | ✅ Yes | - | Tabs provide clear navigation between distinct content areas |
|
|
14
|
+
| Organizing product info (Details, Reviews, Specs) | ✅ Yes | - | Perfect for grouping related but separate information |
|
|
15
|
+
| User needs to see multiple sections at once | ❌ No | Accordion (multiple) | Tabs show one view at a time |
|
|
16
|
+
| Progressive disclosure of content (FAQs) | ❌ No | Accordion | Accordion is better for showing/hiding sections |
|
|
17
|
+
| Sequential steps in a process | ❌ No | Stepper | Stepper shows progress through ordered steps |
|
|
18
|
+
| More than 7-8 tabs needed | ❌ No | Dropdown menu or Sidebar | Too many tabs become hard to navigate |
|
|
19
|
+
|
|
20
|
+
### Component Comparison
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// ✅ Tabs - Switching between related views
|
|
24
|
+
<Tabs.Root colorPalette="primary" defaultValue="overview">
|
|
25
|
+
<Tabs.List>
|
|
26
|
+
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
|
27
|
+
<Tabs.Trigger value="analytics">Analytics</Tabs.Trigger>
|
|
28
|
+
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
|
|
29
|
+
<Tabs.Indicator />
|
|
30
|
+
</Tabs.List>
|
|
31
|
+
<Tabs.Content value="overview">
|
|
32
|
+
<DashboardOverview />
|
|
33
|
+
</Tabs.Content>
|
|
34
|
+
<Tabs.Content value="analytics">
|
|
35
|
+
<AnalyticsView />
|
|
36
|
+
</Tabs.Content>
|
|
37
|
+
<Tabs.Content value="settings">
|
|
38
|
+
<SettingsPanel />
|
|
39
|
+
</Tabs.Content>
|
|
40
|
+
</Tabs.Root>
|
|
41
|
+
|
|
42
|
+
// ❌ Don't use Tabs when users need to see multiple sections - Use Accordion
|
|
43
|
+
<Tabs.Root defaultValue="section1">
|
|
44
|
+
<Tabs.List>
|
|
45
|
+
<Tabs.Trigger value="section1">FAQ 1</Tabs.Trigger>
|
|
46
|
+
<Tabs.Trigger value="section2">FAQ 2</Tabs.Trigger>
|
|
47
|
+
{/* User might want to see multiple FAQs at once */}
|
|
48
|
+
</Tabs.List>
|
|
49
|
+
</Tabs.Root>
|
|
50
|
+
|
|
51
|
+
// ✅ Better: Use Accordion for collapsible sections
|
|
52
|
+
<Accordion.Root multiple>
|
|
53
|
+
<Accordion.Item value="faq1">
|
|
54
|
+
<Accordion.ItemTrigger>
|
|
55
|
+
FAQ 1
|
|
56
|
+
<Accordion.ItemIndicator />
|
|
57
|
+
</Accordion.ItemTrigger>
|
|
58
|
+
<Accordion.ItemContent>Answer 1</Accordion.ItemContent>
|
|
59
|
+
</Accordion.Item>
|
|
60
|
+
<Accordion.Item value="faq2">
|
|
61
|
+
<Accordion.ItemTrigger>
|
|
62
|
+
FAQ 2
|
|
63
|
+
<Accordion.ItemIndicator />
|
|
64
|
+
</Accordion.ItemTrigger>
|
|
65
|
+
<Accordion.ItemContent>Answer 2</Accordion.ItemContent>
|
|
66
|
+
</Accordion.Item>
|
|
67
|
+
</Accordion.Root>
|
|
68
|
+
|
|
69
|
+
// ❌ Don't use Tabs for sequential steps - Use Stepper
|
|
70
|
+
<Tabs.Root defaultValue="step1">
|
|
71
|
+
<Tabs.List>
|
|
72
|
+
<Tabs.Trigger value="step1">Step 1</Tabs.Trigger>
|
|
73
|
+
<Tabs.Trigger value="step2">Step 2</Tabs.Trigger>
|
|
74
|
+
<Tabs.Trigger value="step3">Step 3</Tabs.Trigger>
|
|
75
|
+
</Tabs.List>
|
|
76
|
+
{/* Steps have an order - tabs don't convey sequence */}
|
|
77
|
+
</Tabs.Root>
|
|
78
|
+
|
|
79
|
+
// ✅ Better: Use Stepper for multi-step processes
|
|
80
|
+
<Stepper value={currentStep} onValueChange={setCurrentStep}>
|
|
81
|
+
<Stepper.List>
|
|
82
|
+
<Stepper.Item index={0}>Information</Stepper.Item>
|
|
83
|
+
<Stepper.Item index={1}>Payment</Stepper.Item>
|
|
84
|
+
<Stepper.Item index={2}>Confirmation</Stepper.Item>
|
|
85
|
+
</Stepper.List>
|
|
86
|
+
<Stepper.Content index={0}>Step 1 content</Stepper.Content>
|
|
87
|
+
<Stepper.Content index={1}>Step 2 content</Stepper.Content>
|
|
88
|
+
<Stepper.Content index={2}>Step 3 content</Stepper.Content>
|
|
89
|
+
</Stepper>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Import
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { Tabs } from '@discourser/design-system';
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Component Structure
|
|
99
|
+
|
|
100
|
+
The Tabs component is a **compound component** that follows the composition pattern. All parts must be used together:
|
|
101
|
+
|
|
102
|
+
| Component | Purpose | Required |
|
|
103
|
+
| ------------------- | ------------------------------------------- | ----------- |
|
|
104
|
+
| `Tabs.Root` | Container that manages tab state and layout | Yes |
|
|
105
|
+
| `Tabs.List` | Container for tab triggers | Yes |
|
|
106
|
+
| `Tabs.Trigger` | Individual tab button | Yes |
|
|
107
|
+
| `Tabs.Content` | Panel with content for each tab | Yes |
|
|
108
|
+
| `Tabs.Indicator` | Visual indicator showing active tab | Recommended |
|
|
109
|
+
| `Tabs.Context` | Access tabs state in custom components | Advanced |
|
|
110
|
+
| `Tabs.RootProvider` | Provide external tabs state | Advanced |
|
|
111
|
+
|
|
112
|
+
**Important:** Never use tab parts in isolation. They must be nested within `Tabs.Root`.
|
|
113
|
+
|
|
114
|
+
## Variants
|
|
115
|
+
|
|
116
|
+
The Tabs component supports 3 Material Design 3 inspired variants:
|
|
117
|
+
|
|
118
|
+
| Variant | Visual Style | Usage | When to Use |
|
|
119
|
+
| ---------- | ---------------------------------- | ------------- | ---------------------------------------- |
|
|
120
|
+
| `line` | Underline indicator below tabs | Default style | Primary navigation, content switching |
|
|
121
|
+
| `subtle` | Background highlight on active tab | Soft emphasis | Secondary navigation, embedded tabs |
|
|
122
|
+
| `enclosed` | Pills with background container | High emphasis | Segmented controls, toggle-like behavior |
|
|
123
|
+
|
|
124
|
+
### Visual Characteristics
|
|
125
|
+
|
|
126
|
+
- **line**: Border-bottom on list, sliding indicator line below active tab, primary color accent
|
|
127
|
+
- **subtle**: Rounded background behind active tab, subtle color palette, minimal borders
|
|
128
|
+
- **enclosed**: Contained pill-style buttons with shadow, gray background container, prominent selection
|
|
129
|
+
|
|
130
|
+
## Sizes
|
|
131
|
+
|
|
132
|
+
| Size | Height | Padding (Horizontal) | Font Size | Usage |
|
|
133
|
+
| ---- | ------ | -------------------- | --------- | -------------------------------------------------- |
|
|
134
|
+
| `xs` | 32px | 12px | xs | Compact UI, dense layouts, small screens |
|
|
135
|
+
| `sm` | 36px | 14px | sm | Secondary navigation, sidebars |
|
|
136
|
+
| `md` | 40px | 16px | sm | Default, most use cases |
|
|
137
|
+
| `lg` | 44px | 18px | md | Touch-friendly, mobile-first, prominent navigation |
|
|
138
|
+
|
|
139
|
+
**Recommendation:** Use `md` for most cases. Use `lg` for mobile-first designs or primary navigation.
|
|
140
|
+
|
|
141
|
+
## Props
|
|
142
|
+
|
|
143
|
+
### Root Props
|
|
144
|
+
|
|
145
|
+
| Prop | Type | Default | Description |
|
|
146
|
+
| ---------------- | -------------------------------------- | -------------- | ----------------------------------------------------- |
|
|
147
|
+
| `defaultValue` | `string` | - | Initially selected tab |
|
|
148
|
+
| `value` | `string` | - | Controlled selected tab |
|
|
149
|
+
| `onValueChange` | `(details: { value: string }) => void` | - | Callback when tab selection changes |
|
|
150
|
+
| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Tab list orientation |
|
|
151
|
+
| `activationMode` | `'automatic' \| 'manual'` | `'automatic'` | How tabs activate (on focus vs on click) |
|
|
152
|
+
| `loopFocus` | `boolean` | `true` | Whether arrow key navigation loops at ends |
|
|
153
|
+
| `variant` | `'line' \| 'subtle' \| 'enclosed'` | `'line'` | Visual style variant |
|
|
154
|
+
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Tab size |
|
|
155
|
+
| `fitted` | `boolean` | `false` | Whether tabs stretch to fill container width |
|
|
156
|
+
| `colorPalette` | `string` | - | Color palette for theming (e.g., 'primary', 'accent') |
|
|
157
|
+
|
|
158
|
+
### Trigger Props
|
|
159
|
+
|
|
160
|
+
| Prop | Type | Default | Description |
|
|
161
|
+
| ---------- | ----------- | -------- | ----------------------------- |
|
|
162
|
+
| `value` | `string` | Required | Unique identifier for the tab |
|
|
163
|
+
| `disabled` | `boolean` | `false` | Disable this specific tab |
|
|
164
|
+
| `children` | `ReactNode` | Required | Tab label content |
|
|
165
|
+
|
|
166
|
+
### Content Props
|
|
167
|
+
|
|
168
|
+
| Prop | Type | Default | Description |
|
|
169
|
+
| ---------- | ----------- | -------- | -------------------------------------- |
|
|
170
|
+
| `value` | `string` | Required | Must match corresponding Trigger value |
|
|
171
|
+
| `children` | `ReactNode` | Required | Tab panel content |
|
|
172
|
+
|
|
173
|
+
### List Props
|
|
174
|
+
|
|
175
|
+
Standard HTML div props are supported.
|
|
176
|
+
|
|
177
|
+
### Indicator Props
|
|
178
|
+
|
|
179
|
+
The indicator automatically follows the active tab. No props required.
|
|
180
|
+
|
|
181
|
+
## Examples
|
|
182
|
+
|
|
183
|
+
### Basic Usage
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// Simple horizontal tabs (default)
|
|
187
|
+
<Tabs.Root colorPalette="primary" defaultValue="overview">
|
|
188
|
+
<Tabs.List>
|
|
189
|
+
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
|
190
|
+
<Tabs.Trigger value="details">Details</Tabs.Trigger>
|
|
191
|
+
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
|
|
192
|
+
<Tabs.Indicator />
|
|
193
|
+
</Tabs.List>
|
|
194
|
+
|
|
195
|
+
<Tabs.Content value="overview">
|
|
196
|
+
Overview content goes here
|
|
197
|
+
</Tabs.Content>
|
|
198
|
+
<Tabs.Content value="details">
|
|
199
|
+
Detailed information appears here
|
|
200
|
+
</Tabs.Content>
|
|
201
|
+
<Tabs.Content value="settings">
|
|
202
|
+
Settings panel content
|
|
203
|
+
</Tabs.Content>
|
|
204
|
+
</Tabs.Root>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Controlled State
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
const [activeTab, setActiveTab] = useState('tab1');
|
|
211
|
+
|
|
212
|
+
<Tabs.Root
|
|
213
|
+
colorPalette="primary"
|
|
214
|
+
value={activeTab}
|
|
215
|
+
onValueChange={(details) => setActiveTab(details.value)}
|
|
216
|
+
>
|
|
217
|
+
<Tabs.List>
|
|
218
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
219
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
220
|
+
<Tabs.Indicator />
|
|
221
|
+
</Tabs.List>
|
|
222
|
+
|
|
223
|
+
<Tabs.Content value="tab1">Content 1</Tabs.Content>
|
|
224
|
+
<Tabs.Content value="tab2">Content 2</Tabs.Content>
|
|
225
|
+
</Tabs.Root>
|
|
226
|
+
|
|
227
|
+
<p>Current tab: {activeTab}</p>
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Variant Examples
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
// Line variant (default) - underlined indicator
|
|
234
|
+
<Tabs.Root colorPalette="primary" variant="line" defaultValue="tab1">
|
|
235
|
+
<Tabs.List>
|
|
236
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
237
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
238
|
+
<Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
|
|
239
|
+
<Tabs.Indicator />
|
|
240
|
+
</Tabs.List>
|
|
241
|
+
<Tabs.Content value="tab1">Line variant content</Tabs.Content>
|
|
242
|
+
<Tabs.Content value="tab2">Second tab</Tabs.Content>
|
|
243
|
+
<Tabs.Content value="tab3">Third tab</Tabs.Content>
|
|
244
|
+
</Tabs.Root>
|
|
245
|
+
|
|
246
|
+
// Subtle variant - background highlight
|
|
247
|
+
<Tabs.Root colorPalette="primary" variant="subtle" defaultValue="tab1">
|
|
248
|
+
<Tabs.List>
|
|
249
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
250
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
251
|
+
<Tabs.Indicator />
|
|
252
|
+
</Tabs.List>
|
|
253
|
+
<Tabs.Content value="tab1">Subtle variant content</Tabs.Content>
|
|
254
|
+
<Tabs.Content value="tab2">Second tab</Tabs.Content>
|
|
255
|
+
</Tabs.Root>
|
|
256
|
+
|
|
257
|
+
// Enclosed variant - pill style
|
|
258
|
+
<Tabs.Root colorPalette="primary" variant="enclosed" defaultValue="tab1">
|
|
259
|
+
<Tabs.List>
|
|
260
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
261
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
262
|
+
<Tabs.Indicator />
|
|
263
|
+
</Tabs.List>
|
|
264
|
+
<Tabs.Content value="tab1">Enclosed variant content</Tabs.Content>
|
|
265
|
+
<Tabs.Content value="tab2">Second tab</Tabs.Content>
|
|
266
|
+
</Tabs.Root>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Size Examples
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
// Extra small tabs
|
|
273
|
+
<Tabs.Root size="xs" colorPalette="primary" defaultValue="tab1">
|
|
274
|
+
<Tabs.List>
|
|
275
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
276
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
277
|
+
<Tabs.Indicator />
|
|
278
|
+
</Tabs.List>
|
|
279
|
+
<Tabs.Content value="tab1">Extra small content</Tabs.Content>
|
|
280
|
+
<Tabs.Content value="tab2">Second tab</Tabs.Content>
|
|
281
|
+
</Tabs.Root>
|
|
282
|
+
|
|
283
|
+
// Large tabs (touch-friendly)
|
|
284
|
+
<Tabs.Root size="lg" colorPalette="primary" defaultValue="tab1">
|
|
285
|
+
<Tabs.List>
|
|
286
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
287
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
288
|
+
<Tabs.Indicator />
|
|
289
|
+
</Tabs.List>
|
|
290
|
+
<Tabs.Content value="tab1">Large content</Tabs.Content>
|
|
291
|
+
<Tabs.Content value="tab2">Second tab</Tabs.Content>
|
|
292
|
+
</Tabs.Root>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Vertical Orientation
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
<Tabs.Root orientation="vertical" colorPalette="primary" defaultValue="tab1">
|
|
299
|
+
<Tabs.List>
|
|
300
|
+
<Tabs.Trigger value="tab1">Dashboard</Tabs.Trigger>
|
|
301
|
+
<Tabs.Trigger value="tab2">Analytics</Tabs.Trigger>
|
|
302
|
+
<Tabs.Trigger value="tab3">Reports</Tabs.Trigger>
|
|
303
|
+
<Tabs.Indicator />
|
|
304
|
+
</Tabs.List>
|
|
305
|
+
|
|
306
|
+
<Tabs.Content value="tab1">Dashboard view</Tabs.Content>
|
|
307
|
+
<Tabs.Content value="tab2">Analytics charts</Tabs.Content>
|
|
308
|
+
<Tabs.Content value="tab3">Report tables</Tabs.Content>
|
|
309
|
+
</Tabs.Root>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Fitted Tabs
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
// Tabs stretch to fill available width
|
|
316
|
+
<Tabs.Root fitted colorPalette="primary" defaultValue="tab1">
|
|
317
|
+
<Tabs.List>
|
|
318
|
+
<Tabs.Trigger value="tab1">Overview</Tabs.Trigger>
|
|
319
|
+
<Tabs.Trigger value="tab2">Details</Tabs.Trigger>
|
|
320
|
+
<Tabs.Trigger value="tab3">Settings</Tabs.Trigger>
|
|
321
|
+
<Tabs.Indicator />
|
|
322
|
+
</Tabs.List>
|
|
323
|
+
|
|
324
|
+
<Tabs.Content value="tab1">Overview content</Tabs.Content>
|
|
325
|
+
<Tabs.Content value="tab2">Details content</Tabs.Content>
|
|
326
|
+
<Tabs.Content value="tab3">Settings content</Tabs.Content>
|
|
327
|
+
</Tabs.Root>
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Disabled Tabs
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
<Tabs.Root colorPalette="primary" defaultValue="tab1">
|
|
334
|
+
<Tabs.List>
|
|
335
|
+
<Tabs.Trigger value="tab1">Active Tab</Tabs.Trigger>
|
|
336
|
+
<Tabs.Trigger value="tab2" disabled>
|
|
337
|
+
Disabled Tab
|
|
338
|
+
</Tabs.Trigger>
|
|
339
|
+
<Tabs.Trigger value="tab3">Another Active</Tabs.Trigger>
|
|
340
|
+
<Tabs.Indicator />
|
|
341
|
+
</Tabs.List>
|
|
342
|
+
|
|
343
|
+
<Tabs.Content value="tab1">Active content</Tabs.Content>
|
|
344
|
+
<Tabs.Content value="tab2">Inaccessible content</Tabs.Content>
|
|
345
|
+
<Tabs.Content value="tab3">Another active content</Tabs.Content>
|
|
346
|
+
</Tabs.Root>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Manual Activation Mode
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
// Tabs activate on click/Enter, not on focus
|
|
353
|
+
<Tabs.Root activationMode="manual" colorPalette="primary" defaultValue="tab1">
|
|
354
|
+
<Tabs.List>
|
|
355
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
356
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
357
|
+
<Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
|
|
358
|
+
<Tabs.Indicator />
|
|
359
|
+
</Tabs.List>
|
|
360
|
+
|
|
361
|
+
<Tabs.Content value="tab1">Content 1</Tabs.Content>
|
|
362
|
+
<Tabs.Content value="tab2">Content 2</Tabs.Content>
|
|
363
|
+
<Tabs.Content value="tab3">Content 3</Tabs.Content>
|
|
364
|
+
</Tabs.Root>
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Dynamic Tabs
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
const tabs = [
|
|
371
|
+
{ id: 'home', label: 'Home', content: 'Welcome to the home page' },
|
|
372
|
+
{ id: 'profile', label: 'Profile', content: 'Your profile information' },
|
|
373
|
+
{ id: 'settings', label: 'Settings', content: 'Adjust your preferences' },
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
<Tabs.Root colorPalette="primary" defaultValue={tabs[0].id}>
|
|
377
|
+
<Tabs.List>
|
|
378
|
+
{tabs.map((tab) => (
|
|
379
|
+
<Tabs.Trigger key={tab.id} value={tab.id}>
|
|
380
|
+
{tab.label}
|
|
381
|
+
</Tabs.Trigger>
|
|
382
|
+
))}
|
|
383
|
+
<Tabs.Indicator />
|
|
384
|
+
</Tabs.List>
|
|
385
|
+
|
|
386
|
+
{tabs.map((tab) => (
|
|
387
|
+
<Tabs.Content key={tab.id} value={tab.id}>
|
|
388
|
+
{tab.content}
|
|
389
|
+
</Tabs.Content>
|
|
390
|
+
))}
|
|
391
|
+
</Tabs.Root>
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## Common Patterns
|
|
395
|
+
|
|
396
|
+
### Profile Settings Navigation
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
<Tabs.Root colorPalette="primary" variant="line" defaultValue="account">
|
|
400
|
+
<Tabs.List>
|
|
401
|
+
<Tabs.Trigger value="account">Account</Tabs.Trigger>
|
|
402
|
+
<Tabs.Trigger value="security">Security</Tabs.Trigger>
|
|
403
|
+
<Tabs.Trigger value="notifications">Notifications</Tabs.Trigger>
|
|
404
|
+
<Tabs.Trigger value="billing">Billing</Tabs.Trigger>
|
|
405
|
+
<Tabs.Indicator />
|
|
406
|
+
</Tabs.List>
|
|
407
|
+
|
|
408
|
+
<Tabs.Content value="account">
|
|
409
|
+
<Box p="6">
|
|
410
|
+
<Heading size="lg">Account Settings</Heading>
|
|
411
|
+
<Input label="Username" defaultValue="johndoe" />
|
|
412
|
+
<Input label="Email" defaultValue="john@example.com" />
|
|
413
|
+
<Button>Save Changes</Button>
|
|
414
|
+
</Box>
|
|
415
|
+
</Tabs.Content>
|
|
416
|
+
|
|
417
|
+
<Tabs.Content value="security">
|
|
418
|
+
<Box p="6">
|
|
419
|
+
<Heading size="lg">Security Settings</Heading>
|
|
420
|
+
<Input type="password" label="Current Password" />
|
|
421
|
+
<Input type="password" label="New Password" />
|
|
422
|
+
<Button>Update Password</Button>
|
|
423
|
+
</Box>
|
|
424
|
+
</Tabs.Content>
|
|
425
|
+
|
|
426
|
+
{/* Other content panels */}
|
|
427
|
+
</Tabs.Root>
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Dashboard Views
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
<Tabs.Root
|
|
434
|
+
colorPalette="primary"
|
|
435
|
+
variant="enclosed"
|
|
436
|
+
size="lg"
|
|
437
|
+
defaultValue="overview"
|
|
438
|
+
>
|
|
439
|
+
<Tabs.List>
|
|
440
|
+
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
|
441
|
+
<Tabs.Trigger value="analytics">Analytics</Tabs.Trigger>
|
|
442
|
+
<Tabs.Trigger value="reports">Reports</Tabs.Trigger>
|
|
443
|
+
<Tabs.Indicator />
|
|
444
|
+
</Tabs.List>
|
|
445
|
+
|
|
446
|
+
<Tabs.Content value="overview">
|
|
447
|
+
<Box p="6">
|
|
448
|
+
<h2>Dashboard Overview</h2>
|
|
449
|
+
{/* Stats cards, charts, etc. */}
|
|
450
|
+
</Box>
|
|
451
|
+
</Tabs.Content>
|
|
452
|
+
|
|
453
|
+
<Tabs.Content value="analytics">
|
|
454
|
+
<Box p="6">
|
|
455
|
+
<h2>Analytics</h2>
|
|
456
|
+
{/* Data visualizations */}
|
|
457
|
+
</Box>
|
|
458
|
+
</Tabs.Content>
|
|
459
|
+
|
|
460
|
+
<Tabs.Content value="reports">
|
|
461
|
+
<Box p="6">
|
|
462
|
+
<h2>Reports</h2>
|
|
463
|
+
{/* Report tables */}
|
|
464
|
+
</Box>
|
|
465
|
+
</Tabs.Content>
|
|
466
|
+
</Tabs.Root>
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Segmented Control
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
// Toggle-like behavior with enclosed variant
|
|
473
|
+
<Tabs.Root
|
|
474
|
+
colorPalette="primary"
|
|
475
|
+
variant="enclosed"
|
|
476
|
+
size="sm"
|
|
477
|
+
fitted
|
|
478
|
+
defaultValue="grid"
|
|
479
|
+
>
|
|
480
|
+
<Tabs.List>
|
|
481
|
+
<Tabs.Trigger value="list">List View</Tabs.Trigger>
|
|
482
|
+
<Tabs.Trigger value="grid">Grid View</Tabs.Trigger>
|
|
483
|
+
<Tabs.Indicator />
|
|
484
|
+
</Tabs.List>
|
|
485
|
+
|
|
486
|
+
<Tabs.Content value="list">
|
|
487
|
+
{/* List layout */}
|
|
488
|
+
</Tabs.Content>
|
|
489
|
+
|
|
490
|
+
<Tabs.Content value="grid">
|
|
491
|
+
{/* Grid layout */}
|
|
492
|
+
</Tabs.Content>
|
|
493
|
+
</Tabs.Root>
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### Product Information Tabs
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
<Tabs.Root colorPalette="primary" variant="line" defaultValue="description">
|
|
500
|
+
<Tabs.List>
|
|
501
|
+
<Tabs.Trigger value="description">Description</Tabs.Trigger>
|
|
502
|
+
<Tabs.Trigger value="specs">Specifications</Tabs.Trigger>
|
|
503
|
+
<Tabs.Trigger value="reviews">Reviews (24)</Tabs.Trigger>
|
|
504
|
+
<Tabs.Indicator />
|
|
505
|
+
</Tabs.List>
|
|
506
|
+
|
|
507
|
+
<Tabs.Content value="description">
|
|
508
|
+
<Box p="4">
|
|
509
|
+
<p>Detailed product description with features and benefits...</p>
|
|
510
|
+
</Box>
|
|
511
|
+
</Tabs.Content>
|
|
512
|
+
|
|
513
|
+
<Tabs.Content value="specs">
|
|
514
|
+
<Box p="4">
|
|
515
|
+
<table>
|
|
516
|
+
<tr><td>Dimensions</td><td>10" x 8" x 2"</td></tr>
|
|
517
|
+
<tr><td>Weight</td><td>1.5 lbs</td></tr>
|
|
518
|
+
</table>
|
|
519
|
+
</Box>
|
|
520
|
+
</Tabs.Content>
|
|
521
|
+
|
|
522
|
+
<Tabs.Content value="reviews">
|
|
523
|
+
<Box p="4">
|
|
524
|
+
{/* Review list */}
|
|
525
|
+
</Box>
|
|
526
|
+
</Tabs.Content>
|
|
527
|
+
</Tabs.Root>
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### With Icons
|
|
531
|
+
|
|
532
|
+
```typescript
|
|
533
|
+
import { HomeIcon, UserIcon, SettingsIcon } from 'your-icon-library';
|
|
534
|
+
|
|
535
|
+
<Tabs.Root colorPalette="primary" defaultValue="home">
|
|
536
|
+
<Tabs.List>
|
|
537
|
+
<Tabs.Trigger value="home">
|
|
538
|
+
<HomeIcon />
|
|
539
|
+
Home
|
|
540
|
+
</Tabs.Trigger>
|
|
541
|
+
<Tabs.Trigger value="profile">
|
|
542
|
+
<UserIcon />
|
|
543
|
+
Profile
|
|
544
|
+
</Tabs.Trigger>
|
|
545
|
+
<Tabs.Trigger value="settings">
|
|
546
|
+
<SettingsIcon />
|
|
547
|
+
Settings
|
|
548
|
+
</Tabs.Trigger>
|
|
549
|
+
<Tabs.Indicator />
|
|
550
|
+
</Tabs.List>
|
|
551
|
+
|
|
552
|
+
<Tabs.Content value="home">Home content</Tabs.Content>
|
|
553
|
+
<Tabs.Content value="profile">Profile content</Tabs.Content>
|
|
554
|
+
<Tabs.Content value="settings">Settings content</Tabs.Content>
|
|
555
|
+
</Tabs.Root>
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
## Edge Cases
|
|
559
|
+
|
|
560
|
+
This section covers common edge cases and how to handle them properly.
|
|
561
|
+
|
|
562
|
+
### Dynamic Tabs - Adding/Removing at Runtime
|
|
563
|
+
|
|
564
|
+
**Scenario:** Tabs need to be added or removed dynamically based on user actions or application state.
|
|
565
|
+
|
|
566
|
+
**Solution:**
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
interface Tab {
|
|
570
|
+
id: string;
|
|
571
|
+
label: string;
|
|
572
|
+
content: string;
|
|
573
|
+
closable?: boolean;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const [tabs, setTabs] = useState<Tab[]>([
|
|
577
|
+
{ id: 'tab1', label: 'Home', content: 'Home content', closable: false },
|
|
578
|
+
{ id: 'tab2', label: 'Profile', content: 'Profile content', closable: true },
|
|
579
|
+
]);
|
|
580
|
+
const [activeTab, setActiveTab] = useState('tab1');
|
|
581
|
+
|
|
582
|
+
const addTab = () => {
|
|
583
|
+
const newTabId = `tab${Date.now()}`;
|
|
584
|
+
const newTab: Tab = {
|
|
585
|
+
id: newTabId,
|
|
586
|
+
label: `New Tab ${tabs.length + 1}`,
|
|
587
|
+
content: `Content for new tab`,
|
|
588
|
+
closable: true,
|
|
589
|
+
};
|
|
590
|
+
setTabs([...tabs, newTab]);
|
|
591
|
+
setActiveTab(newTabId); // Activate newly created tab
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const closeTab = (tabId: string) => {
|
|
595
|
+
// Prevent closing if it's the last tab
|
|
596
|
+
if (tabs.length <= 1) return;
|
|
597
|
+
|
|
598
|
+
const tabIndex = tabs.findIndex((t) => t.id === tabId);
|
|
599
|
+
const newTabs = tabs.filter((t) => t.id !== tabId);
|
|
600
|
+
setTabs(newTabs);
|
|
601
|
+
|
|
602
|
+
// If closing active tab, switch to adjacent tab
|
|
603
|
+
if (activeTab === tabId) {
|
|
604
|
+
const newActiveIndex = Math.min(tabIndex, newTabs.length - 1);
|
|
605
|
+
setActiveTab(newTabs[newActiveIndex].id);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
<div>
|
|
610
|
+
<Tabs.Root
|
|
611
|
+
colorPalette="primary"
|
|
612
|
+
value={activeTab}
|
|
613
|
+
onValueChange={(details) => setActiveTab(details.value)}
|
|
614
|
+
>
|
|
615
|
+
<div className={css({ display: 'flex', alignItems: 'center', gap: '2', mb: '2' })}>
|
|
616
|
+
<Tabs.List className={css({ flex: 1 })}>
|
|
617
|
+
{tabs.map((tab) => (
|
|
618
|
+
<Tabs.Trigger key={tab.id} value={tab.id}>
|
|
619
|
+
<span>{tab.label}</span>
|
|
620
|
+
{tab.closable && (
|
|
621
|
+
<button
|
|
622
|
+
className={css({
|
|
623
|
+
ml: '2',
|
|
624
|
+
p: '1',
|
|
625
|
+
borderRadius: 'sm',
|
|
626
|
+
_hover: { bg: 'gray.a3' },
|
|
627
|
+
})}
|
|
628
|
+
onClick={(e) => {
|
|
629
|
+
e.stopPropagation(); // Prevent tab activation
|
|
630
|
+
closeTab(tab.id);
|
|
631
|
+
}}
|
|
632
|
+
aria-label={`Close ${tab.label}`}
|
|
633
|
+
>
|
|
634
|
+
×
|
|
635
|
+
</button>
|
|
636
|
+
)}
|
|
637
|
+
</Tabs.Trigger>
|
|
638
|
+
))}
|
|
639
|
+
<Tabs.Indicator />
|
|
640
|
+
</Tabs.List>
|
|
641
|
+
|
|
642
|
+
<Button size="sm" onClick={addTab} variant="outlined">
|
|
643
|
+
+ Add Tab
|
|
644
|
+
</Button>
|
|
645
|
+
</div>
|
|
646
|
+
|
|
647
|
+
{tabs.map((tab) => (
|
|
648
|
+
<Tabs.Content key={tab.id} value={tab.id}>
|
|
649
|
+
<div className={css({ p: '4' })}>{tab.content}</div>
|
|
650
|
+
</Tabs.Content>
|
|
651
|
+
))}
|
|
652
|
+
</Tabs.Root>
|
|
653
|
+
</div>
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**Best practices:**
|
|
657
|
+
|
|
658
|
+
- Always maintain at least one tab to prevent empty state
|
|
659
|
+
- Switch to adjacent tab when closing the active tab
|
|
660
|
+
- Stop event propagation on close buttons to prevent tab activation
|
|
661
|
+
- Provide clear visual indicators for closable tabs
|
|
662
|
+
- Consider confirming before closing tabs with unsaved changes
|
|
663
|
+
|
|
664
|
+
---
|
|
665
|
+
|
|
666
|
+
### Too Many Tabs - Handling Overflow
|
|
667
|
+
|
|
668
|
+
**Scenario:** When there are too many tabs to fit horizontally, implement scrolling or alternative navigation patterns.
|
|
669
|
+
|
|
670
|
+
**Solution:**
|
|
671
|
+
|
|
672
|
+
```typescript
|
|
673
|
+
const manyTabs = Array.from({ length: 12 }, (_, i) => ({
|
|
674
|
+
id: `tab${i + 1}`,
|
|
675
|
+
label: `Tab ${i + 1}`,
|
|
676
|
+
content: `Content for tab ${i + 1}`,
|
|
677
|
+
}));
|
|
678
|
+
|
|
679
|
+
const [activeTab, setActiveTab] = useState('tab1');
|
|
680
|
+
|
|
681
|
+
<Tabs.Root
|
|
682
|
+
colorPalette="primary"
|
|
683
|
+
value={activeTab}
|
|
684
|
+
onValueChange={(details) => setActiveTab(details.value)}
|
|
685
|
+
>
|
|
686
|
+
{/* Horizontal scroll container */}
|
|
687
|
+
<div
|
|
688
|
+
className={css({
|
|
689
|
+
overflowX: 'auto',
|
|
690
|
+
overflowY: 'hidden',
|
|
691
|
+
WebkitOverflowScrolling: 'touch', // Smooth scrolling on iOS
|
|
692
|
+
scrollbarWidth: 'thin', // Firefox
|
|
693
|
+
'&::-webkit-scrollbar': {
|
|
694
|
+
height: '6px',
|
|
695
|
+
},
|
|
696
|
+
'&::-webkit-scrollbar-thumb': {
|
|
697
|
+
bg: 'gray.a5',
|
|
698
|
+
borderRadius: 'full',
|
|
699
|
+
},
|
|
700
|
+
})}
|
|
701
|
+
>
|
|
702
|
+
<Tabs.List className={css({ minWidth: 'max-content' })}>
|
|
703
|
+
{manyTabs.map((tab) => (
|
|
704
|
+
<Tabs.Trigger
|
|
705
|
+
key={tab.id}
|
|
706
|
+
value={tab.id}
|
|
707
|
+
className={css({ whiteSpace: 'nowrap', flexShrink: 0 })}
|
|
708
|
+
>
|
|
709
|
+
{tab.label}
|
|
710
|
+
</Tabs.Trigger>
|
|
711
|
+
))}
|
|
712
|
+
<Tabs.Indicator />
|
|
713
|
+
</Tabs.List>
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
{manyTabs.map((tab) => (
|
|
717
|
+
<Tabs.Content key={tab.id} value={tab.id}>
|
|
718
|
+
<div className={css({ p: '4' })}>{tab.content}</div>
|
|
719
|
+
</Tabs.Content>
|
|
720
|
+
))}
|
|
721
|
+
</Tabs.Root>
|
|
722
|
+
|
|
723
|
+
{/* Alternative: Dropdown for overflow tabs */}
|
|
724
|
+
<Tabs.Root
|
|
725
|
+
colorPalette="primary"
|
|
726
|
+
value={activeTab}
|
|
727
|
+
onValueChange={(details) => setActiveTab(details.value)}
|
|
728
|
+
>
|
|
729
|
+
<div className={css({ display: 'flex', alignItems: 'center' })}>
|
|
730
|
+
<Tabs.List>
|
|
731
|
+
{/* Show first 5 tabs */}
|
|
732
|
+
{manyTabs.slice(0, 5).map((tab) => (
|
|
733
|
+
<Tabs.Trigger key={tab.id} value={tab.id}>
|
|
734
|
+
{tab.label}
|
|
735
|
+
</Tabs.Trigger>
|
|
736
|
+
))}
|
|
737
|
+
<Tabs.Indicator />
|
|
738
|
+
</Tabs.List>
|
|
739
|
+
|
|
740
|
+
{/* Dropdown for remaining tabs */}
|
|
741
|
+
{manyTabs.length > 5 && (
|
|
742
|
+
<Select.Root
|
|
743
|
+
items={manyTabs.slice(5)}
|
|
744
|
+
itemToValue={(item) => item.id}
|
|
745
|
+
itemToString={(item) => item.label}
|
|
746
|
+
value={[activeTab]}
|
|
747
|
+
onValueChange={(details) => setActiveTab(details.value[0])}
|
|
748
|
+
>
|
|
749
|
+
<Select.Control>
|
|
750
|
+
<Select.Trigger>
|
|
751
|
+
<Select.ValueText placeholder="More..." />
|
|
752
|
+
</Select.Trigger>
|
|
753
|
+
</Select.Control>
|
|
754
|
+
<Select.Positioner>
|
|
755
|
+
<Select.Content>
|
|
756
|
+
<Select.List>
|
|
757
|
+
{manyTabs.slice(5).map((tab) => (
|
|
758
|
+
<Select.Item key={tab.id} item={tab}>
|
|
759
|
+
<Select.ItemText>{tab.label}</Select.ItemText>
|
|
760
|
+
</Select.Item>
|
|
761
|
+
))}
|
|
762
|
+
</Select.List>
|
|
763
|
+
</Select.Content>
|
|
764
|
+
</Select.Positioner>
|
|
765
|
+
</Select.Root>
|
|
766
|
+
)}
|
|
767
|
+
</div>
|
|
768
|
+
|
|
769
|
+
{manyTabs.map((tab) => (
|
|
770
|
+
<Tabs.Content key={tab.id} value={tab.id}>
|
|
771
|
+
<div className={css({ p: '4' })}>{tab.content}</div>
|
|
772
|
+
</Tabs.Content>
|
|
773
|
+
))}
|
|
774
|
+
</Tabs.Root>
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
**Best practices:**
|
|
778
|
+
|
|
779
|
+
- Limit visible tabs to 7-8 maximum for usability
|
|
780
|
+
- Use horizontal scrolling with clear scroll indicators
|
|
781
|
+
- Consider a dropdown menu for overflow tabs
|
|
782
|
+
- Add visual cues (shadows/gradients) to indicate more tabs
|
|
783
|
+
- On mobile, consider switching to a different navigation pattern
|
|
784
|
+
|
|
785
|
+
---
|
|
786
|
+
|
|
787
|
+
### Disabled Tabs - Conditional Access
|
|
788
|
+
|
|
789
|
+
**Scenario:** Some tabs should be visible but not accessible due to permissions, incomplete prerequisites, or application state.
|
|
790
|
+
|
|
791
|
+
**Solution:**
|
|
792
|
+
|
|
793
|
+
```typescript
|
|
794
|
+
interface TabConfig {
|
|
795
|
+
id: string;
|
|
796
|
+
label: string;
|
|
797
|
+
content: string;
|
|
798
|
+
disabled: boolean;
|
|
799
|
+
disabledReason?: string;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const [hasPermission, setHasPermission] = useState(false);
|
|
803
|
+
const [completedStep1, setCompletedStep1] = useState(false);
|
|
804
|
+
|
|
805
|
+
const tabConfigs: TabConfig[] = [
|
|
806
|
+
{
|
|
807
|
+
id: 'overview',
|
|
808
|
+
label: 'Overview',
|
|
809
|
+
content: 'Overview content available to all',
|
|
810
|
+
disabled: false,
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
id: 'analytics',
|
|
814
|
+
label: 'Analytics',
|
|
815
|
+
content: 'Analytics content',
|
|
816
|
+
disabled: !hasPermission,
|
|
817
|
+
disabledReason: 'Requires Pro subscription',
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
id: 'advanced',
|
|
821
|
+
label: 'Advanced',
|
|
822
|
+
content: 'Advanced settings',
|
|
823
|
+
disabled: !completedStep1,
|
|
824
|
+
disabledReason: 'Complete overview first',
|
|
825
|
+
},
|
|
826
|
+
];
|
|
827
|
+
|
|
828
|
+
const [activeTab, setActiveTab] = useState('overview');
|
|
829
|
+
const [tooltipTab, setTooltipTab] = useState<string | null>(null);
|
|
830
|
+
|
|
831
|
+
<div>
|
|
832
|
+
{/* Controls for demo */}
|
|
833
|
+
<div className={css({ mb: '4', p: '3', bg: 'gray.a2', borderRadius: 'md' })}>
|
|
834
|
+
<label>
|
|
835
|
+
<input
|
|
836
|
+
type="checkbox"
|
|
837
|
+
checked={hasPermission}
|
|
838
|
+
onChange={(e) => setHasPermission(e.target.checked)}
|
|
839
|
+
/>
|
|
840
|
+
<span className={css({ ml: '2' })}>Has Pro subscription</span>
|
|
841
|
+
</label>
|
|
842
|
+
<label className={css({ ml: '4' })}>
|
|
843
|
+
<input
|
|
844
|
+
type="checkbox"
|
|
845
|
+
checked={completedStep1}
|
|
846
|
+
onChange={(e) => setCompletedStep1(e.target.checked)}
|
|
847
|
+
/>
|
|
848
|
+
<span className={css({ ml: '2' })}>Completed overview</span>
|
|
849
|
+
</label>
|
|
850
|
+
</div>
|
|
851
|
+
|
|
852
|
+
<Tabs.Root
|
|
853
|
+
colorPalette="primary"
|
|
854
|
+
value={activeTab}
|
|
855
|
+
onValueChange={(details) => setActiveTab(details.value)}
|
|
856
|
+
>
|
|
857
|
+
<Tabs.List>
|
|
858
|
+
{tabConfigs.map((tab) => (
|
|
859
|
+
<div
|
|
860
|
+
key={tab.id}
|
|
861
|
+
className={css({ position: 'relative' })}
|
|
862
|
+
onMouseEnter={() => tab.disabled && setTooltipTab(tab.id)}
|
|
863
|
+
onMouseLeave={() => setTooltipTab(null)}
|
|
864
|
+
>
|
|
865
|
+
<Tabs.Trigger value={tab.id} disabled={tab.disabled}>
|
|
866
|
+
{tab.label}
|
|
867
|
+
{tab.disabled && (
|
|
868
|
+
<span className={css({ ml: '1', fontSize: 'xs', color: 'fg.muted' })}>
|
|
869
|
+
🔒
|
|
870
|
+
</span>
|
|
871
|
+
)}
|
|
872
|
+
</Tabs.Trigger>
|
|
873
|
+
|
|
874
|
+
{/* Tooltip for disabled tabs */}
|
|
875
|
+
{tab.disabled && tooltipTab === tab.id && tab.disabledReason && (
|
|
876
|
+
<div
|
|
877
|
+
className={css({
|
|
878
|
+
position: 'absolute',
|
|
879
|
+
top: 'full',
|
|
880
|
+
left: '50%',
|
|
881
|
+
transform: 'translateX(-50%)',
|
|
882
|
+
mt: '2',
|
|
883
|
+
p: '2',
|
|
884
|
+
bg: 'gray.12',
|
|
885
|
+
color: 'white',
|
|
886
|
+
fontSize: 'xs',
|
|
887
|
+
borderRadius: 'sm',
|
|
888
|
+
whiteSpace: 'nowrap',
|
|
889
|
+
zIndex: 10,
|
|
890
|
+
})}
|
|
891
|
+
>
|
|
892
|
+
{tab.disabledReason}
|
|
893
|
+
</div>
|
|
894
|
+
)}
|
|
895
|
+
</div>
|
|
896
|
+
))}
|
|
897
|
+
<Tabs.Indicator />
|
|
898
|
+
</Tabs.List>
|
|
899
|
+
|
|
900
|
+
{tabConfigs.map((tab) => (
|
|
901
|
+
<Tabs.Content key={tab.id} value={tab.id}>
|
|
902
|
+
<div className={css({ p: '4' })}>{tab.content}</div>
|
|
903
|
+
</Tabs.Content>
|
|
904
|
+
))}
|
|
905
|
+
</Tabs.Root>
|
|
906
|
+
</div>
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
**Best practices:**
|
|
910
|
+
|
|
911
|
+
- Clearly indicate why a tab is disabled with tooltips or labels
|
|
912
|
+
- Use lock icons or visual indicators for disabled tabs
|
|
913
|
+
- Keep disabled tabs visible for discoverability
|
|
914
|
+
- Provide actionable steps to enable disabled tabs when possible
|
|
915
|
+
- Ensure `aria-disabled` is properly communicated to screen readers
|
|
916
|
+
|
|
917
|
+
---
|
|
918
|
+
|
|
919
|
+
### Async Tab Content - Lazy Loading
|
|
920
|
+
|
|
921
|
+
**Scenario:** Tab content is expensive to render or requires data fetching, so it should only load when the tab is first selected.
|
|
922
|
+
|
|
923
|
+
**Solution:**
|
|
924
|
+
|
|
925
|
+
```typescript
|
|
926
|
+
interface TabData {
|
|
927
|
+
id: string;
|
|
928
|
+
label: string;
|
|
929
|
+
loaded: boolean;
|
|
930
|
+
loading: boolean;
|
|
931
|
+
data: any | null;
|
|
932
|
+
error: string | null;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const [activeTab, setActiveTab] = useState('overview');
|
|
936
|
+
const [tabsData, setTabsData] = useState<Record<string, TabData>>({
|
|
937
|
+
overview: { id: 'overview', label: 'Overview', loaded: true, loading: false, data: {}, error: null },
|
|
938
|
+
analytics: { id: 'analytics', label: 'Analytics', loaded: false, loading: false, data: null, error: null },
|
|
939
|
+
reports: { id: 'reports', label: 'Reports', loaded: false, loading: false, data: null, error: null },
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
// Load data when tab becomes active
|
|
943
|
+
useEffect(() => {
|
|
944
|
+
const currentTab = tabsData[activeTab];
|
|
945
|
+
|
|
946
|
+
if (!currentTab.loaded && !currentTab.loading) {
|
|
947
|
+
// Start loading
|
|
948
|
+
setTabsData((prev) => ({
|
|
949
|
+
...prev,
|
|
950
|
+
[activeTab]: { ...prev[activeTab], loading: true },
|
|
951
|
+
}));
|
|
952
|
+
|
|
953
|
+
// Simulate async data fetch
|
|
954
|
+
fetch(`/api/${activeTab}`)
|
|
955
|
+
.then((res) => res.json())
|
|
956
|
+
.then((data) => {
|
|
957
|
+
setTabsData((prev) => ({
|
|
958
|
+
...prev,
|
|
959
|
+
[activeTab]: {
|
|
960
|
+
...prev[activeTab],
|
|
961
|
+
loaded: true,
|
|
962
|
+
loading: false,
|
|
963
|
+
data,
|
|
964
|
+
error: null,
|
|
965
|
+
},
|
|
966
|
+
}));
|
|
967
|
+
})
|
|
968
|
+
.catch((error) => {
|
|
969
|
+
setTabsData((prev) => ({
|
|
970
|
+
...prev,
|
|
971
|
+
[activeTab]: {
|
|
972
|
+
...prev[activeTab],
|
|
973
|
+
loading: false,
|
|
974
|
+
error: error.message,
|
|
975
|
+
},
|
|
976
|
+
}));
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}, [activeTab]);
|
|
980
|
+
|
|
981
|
+
<Tabs.Root
|
|
982
|
+
colorPalette="primary"
|
|
983
|
+
value={activeTab}
|
|
984
|
+
onValueChange={(details) => setActiveTab(details.value)}
|
|
985
|
+
>
|
|
986
|
+
<Tabs.List>
|
|
987
|
+
{Object.values(tabsData).map((tab) => (
|
|
988
|
+
<Tabs.Trigger key={tab.id} value={tab.id}>
|
|
989
|
+
{tab.label}
|
|
990
|
+
{tab.loading && (
|
|
991
|
+
<span className={css({ ml: '2' })}>
|
|
992
|
+
<Spinner size="xs" />
|
|
993
|
+
</span>
|
|
994
|
+
)}
|
|
995
|
+
</Tabs.Trigger>
|
|
996
|
+
))}
|
|
997
|
+
<Tabs.Indicator />
|
|
998
|
+
</Tabs.List>
|
|
999
|
+
|
|
1000
|
+
{Object.values(tabsData).map((tab) => (
|
|
1001
|
+
<Tabs.Content key={tab.id} value={tab.id}>
|
|
1002
|
+
<div className={css({ p: '4', minHeight: '200px' })}>
|
|
1003
|
+
{tab.loading && (
|
|
1004
|
+
<div className={css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2' })}>
|
|
1005
|
+
<Spinner />
|
|
1006
|
+
<p className={css({ color: 'fg.muted' })}>Loading {tab.label.toLowerCase()}...</p>
|
|
1007
|
+
</div>
|
|
1008
|
+
)}
|
|
1009
|
+
|
|
1010
|
+
{tab.error && (
|
|
1011
|
+
<div className={css({ textAlign: 'center', color: 'error.fg' })}>
|
|
1012
|
+
<p>Failed to load {tab.label.toLowerCase()}</p>
|
|
1013
|
+
<p className={css({ fontSize: 'sm', mt: '2' })}>{tab.error}</p>
|
|
1014
|
+
</div>
|
|
1015
|
+
)}
|
|
1016
|
+
|
|
1017
|
+
{tab.loaded && !tab.loading && !tab.error && (
|
|
1018
|
+
<div>
|
|
1019
|
+
<h3>{tab.label} Content</h3>
|
|
1020
|
+
<pre>{JSON.stringify(tab.data, null, 2)}</pre>
|
|
1021
|
+
</div>
|
|
1022
|
+
)}
|
|
1023
|
+
</div>
|
|
1024
|
+
</Tabs.Content>
|
|
1025
|
+
))}
|
|
1026
|
+
</Tabs.Root>
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
**Best practices:**
|
|
1030
|
+
|
|
1031
|
+
- Show loading indicators in both the tab trigger and content area
|
|
1032
|
+
- Cache loaded data to avoid refetching when switching back
|
|
1033
|
+
- Handle errors gracefully with retry options
|
|
1034
|
+
- Set minimum heights to prevent layout shifts during loading
|
|
1035
|
+
- Use `activationMode="manual"` for expensive operations to prevent accidental loads
|
|
1036
|
+
|
|
1037
|
+
---
|
|
1038
|
+
|
|
1039
|
+
### Keyboard Navigation - Advanced Controls
|
|
1040
|
+
|
|
1041
|
+
**Scenario:** Implementing comprehensive keyboard navigation including Home, End, and proper arrow key behavior with looping.
|
|
1042
|
+
|
|
1043
|
+
**Solution:**
|
|
1044
|
+
|
|
1045
|
+
```typescript
|
|
1046
|
+
const tabs = [
|
|
1047
|
+
{ id: 'home', label: 'Home', content: 'Home content' },
|
|
1048
|
+
{ id: 'profile', label: 'Profile', content: 'Profile content' },
|
|
1049
|
+
{ id: 'settings', label: 'Settings', content: 'Settings content' },
|
|
1050
|
+
{ id: 'help', label: 'Help', content: 'Help content' },
|
|
1051
|
+
];
|
|
1052
|
+
|
|
1053
|
+
const [activeTab, setActiveTab] = useState('home');
|
|
1054
|
+
const [lastInteraction, setLastInteraction] = useState<'mouse' | 'keyboard'>('mouse');
|
|
1055
|
+
|
|
1056
|
+
<div>
|
|
1057
|
+
{/* Keyboard shortcuts legend */}
|
|
1058
|
+
<div className={css({ mb: '3', p: '2', bg: 'gray.a2', borderRadius: 'sm', fontSize: 'sm' })}>
|
|
1059
|
+
<strong>Keyboard shortcuts:</strong> Arrow keys to navigate, Home/End for first/last tab,
|
|
1060
|
+
Enter/Space to activate (manual mode)
|
|
1061
|
+
</div>
|
|
1062
|
+
|
|
1063
|
+
<Tabs.Root
|
|
1064
|
+
colorPalette="primary"
|
|
1065
|
+
value={activeTab}
|
|
1066
|
+
onValueChange={(details) => {
|
|
1067
|
+
setActiveTab(details.value);
|
|
1068
|
+
}}
|
|
1069
|
+
// Enable keyboard navigation
|
|
1070
|
+
loopFocus={true} // Arrow keys loop from last to first
|
|
1071
|
+
activationMode="automatic" // Change to "manual" for explicit Enter/Space activation
|
|
1072
|
+
onFocusChange={(details) => {
|
|
1073
|
+
// Track interaction type for analytics or styling
|
|
1074
|
+
setLastInteraction('keyboard');
|
|
1075
|
+
}}
|
|
1076
|
+
>
|
|
1077
|
+
<Tabs.List
|
|
1078
|
+
onMouseDown={() => setLastInteraction('mouse')}
|
|
1079
|
+
className={css({
|
|
1080
|
+
// Add visual indicator for keyboard focus
|
|
1081
|
+
'& [data-focus]': {
|
|
1082
|
+
outline: lastInteraction === 'keyboard' ? '2px solid' : 'none',
|
|
1083
|
+
outlineColor: 'primary.9',
|
|
1084
|
+
outlineOffset: '2px',
|
|
1085
|
+
},
|
|
1086
|
+
})}
|
|
1087
|
+
>
|
|
1088
|
+
{tabs.map((tab) => (
|
|
1089
|
+
<Tabs.Trigger key={tab.id} value={tab.id}>
|
|
1090
|
+
{tab.label}
|
|
1091
|
+
</Tabs.Trigger>
|
|
1092
|
+
))}
|
|
1093
|
+
<Tabs.Indicator />
|
|
1094
|
+
</Tabs.List>
|
|
1095
|
+
|
|
1096
|
+
{tabs.map((tab) => (
|
|
1097
|
+
<Tabs.Content
|
|
1098
|
+
key={tab.id}
|
|
1099
|
+
value={tab.id}
|
|
1100
|
+
// Make content focusable for screen readers
|
|
1101
|
+
tabIndex={0}
|
|
1102
|
+
>
|
|
1103
|
+
<div className={css({ p: '4' })}>{tab.content}</div>
|
|
1104
|
+
</Tabs.Content>
|
|
1105
|
+
))}
|
|
1106
|
+
</Tabs.Root>
|
|
1107
|
+
|
|
1108
|
+
<div className={css({ mt: '3', fontSize: 'sm', color: 'fg.muted' })}>
|
|
1109
|
+
Last interaction: {lastInteraction}
|
|
1110
|
+
</div>
|
|
1111
|
+
</div>
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
**Best practices:**
|
|
1115
|
+
|
|
1116
|
+
- Enable `loopFocus` for intuitive circular navigation
|
|
1117
|
+
- Use `activationMode="manual"` for tabs with expensive content
|
|
1118
|
+
- Show clear focus indicators for keyboard navigation
|
|
1119
|
+
- Make tab content focusable for screen reader access
|
|
1120
|
+
- Test with keyboard-only navigation to ensure full accessibility
|
|
1121
|
+
- Document keyboard shortcuts for users
|
|
1122
|
+
|
|
1123
|
+
---
|
|
1124
|
+
|
|
1125
|
+
## DO NOT
|
|
1126
|
+
|
|
1127
|
+
```typescript
|
|
1128
|
+
// ❌ Don't use tab parts without Root
|
|
1129
|
+
<Tabs.List>
|
|
1130
|
+
<Tabs.Trigger value="tab1">Won't work</Tabs.Trigger>
|
|
1131
|
+
</Tabs.List>
|
|
1132
|
+
|
|
1133
|
+
// ❌ Don't forget matching values
|
|
1134
|
+
<Tabs.Root defaultValue="tab1">
|
|
1135
|
+
<Tabs.List>
|
|
1136
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1137
|
+
</Tabs.List>
|
|
1138
|
+
<Tabs.Content value="different-value"> {/* Won't show! */}
|
|
1139
|
+
Content
|
|
1140
|
+
</Tabs.Content>
|
|
1141
|
+
</Tabs.Root>
|
|
1142
|
+
|
|
1143
|
+
// ❌ Don't use duplicate values
|
|
1144
|
+
<Tabs.Root defaultValue="tab1">
|
|
1145
|
+
<Tabs.List>
|
|
1146
|
+
<Tabs.Trigger value="same">Tab 1</Tabs.Trigger>
|
|
1147
|
+
<Tabs.Trigger value="same">Tab 2</Tabs.Trigger> {/* Collision! */}
|
|
1148
|
+
</Tabs.List>
|
|
1149
|
+
</Tabs.Root>
|
|
1150
|
+
|
|
1151
|
+
// ❌ Don't nest interactive elements in Trigger
|
|
1152
|
+
<Tabs.Trigger value="tab1">
|
|
1153
|
+
<button>Nested button</button> {/* Breaks accessibility */}
|
|
1154
|
+
</Tabs.Trigger>
|
|
1155
|
+
|
|
1156
|
+
// ❌ Don't omit Indicator (poor visual feedback)
|
|
1157
|
+
<Tabs.List>
|
|
1158
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1159
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
1160
|
+
{/* Missing <Tabs.Indicator /> */}
|
|
1161
|
+
</Tabs.List>
|
|
1162
|
+
|
|
1163
|
+
// ❌ Don't use tabs for sequential steps (use Stepper instead)
|
|
1164
|
+
<Tabs.Root defaultValue="step1">
|
|
1165
|
+
<Tabs.List>
|
|
1166
|
+
<Tabs.Trigger value="step1">Step 1</Tabs.Trigger>
|
|
1167
|
+
<Tabs.Trigger value="step2">Step 2</Tabs.Trigger>
|
|
1168
|
+
<Tabs.Trigger value="step3">Step 3</Tabs.Trigger>
|
|
1169
|
+
</Tabs.List>
|
|
1170
|
+
{/* This should be a Stepper, not Tabs */}
|
|
1171
|
+
</Tabs.Root>
|
|
1172
|
+
|
|
1173
|
+
// ❌ Don't use too many tabs (>7 becomes hard to scan)
|
|
1174
|
+
<Tabs.List>
|
|
1175
|
+
{/* 10+ tabs is overwhelming - consider dropdown or hierarchical nav */}
|
|
1176
|
+
</Tabs.List>
|
|
1177
|
+
|
|
1178
|
+
// ✅ Use compound components properly
|
|
1179
|
+
<Tabs.Root colorPalette="primary" defaultValue="tab1">
|
|
1180
|
+
<Tabs.List>
|
|
1181
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1182
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
1183
|
+
<Tabs.Indicator />
|
|
1184
|
+
</Tabs.List>
|
|
1185
|
+
<Tabs.Content value="tab1">Content 1</Tabs.Content>
|
|
1186
|
+
<Tabs.Content value="tab2">Content 2</Tabs.Content>
|
|
1187
|
+
</Tabs.Root>
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
## Accessibility
|
|
1191
|
+
|
|
1192
|
+
The Tabs component follows WCAG 2.1 Level AA standards and implements WAI-ARIA Tabs Pattern:
|
|
1193
|
+
|
|
1194
|
+
- **Keyboard Navigation**:
|
|
1195
|
+
- `Tab`: Focus into/out of tab list
|
|
1196
|
+
- `ArrowRight` / `ArrowDown`: Next tab
|
|
1197
|
+
- `ArrowLeft` / `ArrowUp`: Previous tab
|
|
1198
|
+
- `Home`: First tab
|
|
1199
|
+
- `End`: Last tab
|
|
1200
|
+
- `Enter` / `Space`: Activate focused tab (manual mode)
|
|
1201
|
+
|
|
1202
|
+
- **ARIA Attributes**: Automatically managed
|
|
1203
|
+
- `role="tablist"` on List
|
|
1204
|
+
- `role="tab"` on Trigger
|
|
1205
|
+
- `role="tabpanel"` on Content
|
|
1206
|
+
- `aria-selected` on triggers (true/false)
|
|
1207
|
+
- `aria-controls` links trigger to panel
|
|
1208
|
+
- `aria-labelledby` links panel to trigger
|
|
1209
|
+
- `aria-orientation` on vertical tab lists
|
|
1210
|
+
- `aria-disabled` on disabled tabs
|
|
1211
|
+
|
|
1212
|
+
- **Focus Management**:
|
|
1213
|
+
- Clear focus indicators on keyboard navigation
|
|
1214
|
+
- Focus moves to active tab trigger when entering tab list
|
|
1215
|
+
- Tab panel is focusable for screen reader access
|
|
1216
|
+
|
|
1217
|
+
- **Activation Modes**:
|
|
1218
|
+
- **Automatic** (default): Tab activates on arrow key focus
|
|
1219
|
+
- **Manual**: Tab activates only on Enter/Space (better for dynamic content)
|
|
1220
|
+
|
|
1221
|
+
### Accessibility Best Practices
|
|
1222
|
+
|
|
1223
|
+
```typescript
|
|
1224
|
+
// ✅ Use descriptive tab labels
|
|
1225
|
+
<Tabs.Trigger value="account">Account Settings</Tabs.Trigger>
|
|
1226
|
+
|
|
1227
|
+
// ✅ Use manual activation for tabs that load data
|
|
1228
|
+
<Tabs.Root activationMode="manual" colorPalette="primary" defaultValue="tab1">
|
|
1229
|
+
{/* Prevents triggering API calls on arrow key navigation */}
|
|
1230
|
+
</Tabs.Root>
|
|
1231
|
+
|
|
1232
|
+
// ✅ Provide meaningful content in panels
|
|
1233
|
+
<Tabs.Content value="overview">
|
|
1234
|
+
<h2>Overview</h2>
|
|
1235
|
+
<p>Well-structured content with headings...</p>
|
|
1236
|
+
</Tabs.Content>
|
|
1237
|
+
|
|
1238
|
+
// ✅ Indicate disabled state clearly in label
|
|
1239
|
+
<Tabs.Trigger value="premium" disabled>
|
|
1240
|
+
Premium Features (Upgrade Required)
|
|
1241
|
+
</Tabs.Trigger>
|
|
1242
|
+
|
|
1243
|
+
// ✅ Use icons with text labels, not icon-only
|
|
1244
|
+
<Tabs.Trigger value="home">
|
|
1245
|
+
<HomeIcon aria-hidden="true" />
|
|
1246
|
+
Home
|
|
1247
|
+
</Tabs.Trigger>
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
## Variant Selection Guide
|
|
1251
|
+
|
|
1252
|
+
| Scenario | Recommended Variant | Reasoning |
|
|
1253
|
+
| -------------------- | -------------------- | -------------------------------------------------- |
|
|
1254
|
+
| Primary navigation | `line` | Clear, familiar pattern for main content switching |
|
|
1255
|
+
| Secondary navigation | `subtle` | Lower emphasis for nested or supplementary tabs |
|
|
1256
|
+
| Segmented control | `enclosed` | Clear selection state, toggle-like behavior |
|
|
1257
|
+
| Settings sections | `line` | Clean, organized appearance |
|
|
1258
|
+
| Dashboard views | `line` or `enclosed` | Depends on design system aesthetics |
|
|
1259
|
+
| Sidebar navigation | `subtle` | Integrates well with sidebar design |
|
|
1260
|
+
| Toolbar controls | `enclosed` | Compact, clear selection |
|
|
1261
|
+
|
|
1262
|
+
## State Behaviors
|
|
1263
|
+
|
|
1264
|
+
| State | Visual Change | Behavior |
|
|
1265
|
+
| ------------ | --------------------------------- | -------------------------------------- |
|
|
1266
|
+
| **Default** | Muted text color | Tab is inactive but selectable |
|
|
1267
|
+
| **Selected** | Indicator visible, accent color | Tab panel content is shown |
|
|
1268
|
+
| **Hover** | Subtle background or color change | Visual feedback on interactive element |
|
|
1269
|
+
| **Focus** | Focus ring visible | Keyboard navigation indicator |
|
|
1270
|
+
| **Disabled** | 38% opacity, cursor not-allowed | Tab cannot be activated |
|
|
1271
|
+
|
|
1272
|
+
## Indicator Animation
|
|
1273
|
+
|
|
1274
|
+
The `Tabs.Indicator` smoothly animates between tabs using CSS transforms:
|
|
1275
|
+
|
|
1276
|
+
- **Horizontal**: Slides left/right with width adjustment
|
|
1277
|
+
- **Vertical**: Slides up/down with height adjustment
|
|
1278
|
+
- **Transition**: Smooth, native feel with GPU acceleration
|
|
1279
|
+
|
|
1280
|
+
## Orientation Guidelines
|
|
1281
|
+
|
|
1282
|
+
### Horizontal Tabs (Default)
|
|
1283
|
+
|
|
1284
|
+
- **Best for**: Primary navigation, most use cases
|
|
1285
|
+
- **Layout**: Tabs arranged left-to-right
|
|
1286
|
+
- **Responsiveness**: May need overflow handling on small screens
|
|
1287
|
+
- **Common placement**: Top of content area
|
|
1288
|
+
|
|
1289
|
+
### Vertical Tabs
|
|
1290
|
+
|
|
1291
|
+
- **Best for**: Sidebar navigation, settings with many sections
|
|
1292
|
+
- **Layout**: Tabs arranged top-to-bottom
|
|
1293
|
+
- **Responsiveness**: Works well on all screen sizes
|
|
1294
|
+
- **Common placement**: Left or right side of content
|
|
1295
|
+
|
|
1296
|
+
```typescript
|
|
1297
|
+
// Responsive orientation
|
|
1298
|
+
<Tabs.Root
|
|
1299
|
+
orientation={{ base: 'horizontal', md: 'vertical' }}
|
|
1300
|
+
colorPalette="primary"
|
|
1301
|
+
defaultValue="tab1"
|
|
1302
|
+
>
|
|
1303
|
+
{/* Horizontal on mobile, vertical on desktop */}
|
|
1304
|
+
</Tabs.Root>
|
|
1305
|
+
```
|
|
1306
|
+
|
|
1307
|
+
## Responsive Considerations
|
|
1308
|
+
|
|
1309
|
+
```typescript
|
|
1310
|
+
// Responsive size
|
|
1311
|
+
<Tabs.Root
|
|
1312
|
+
size={{ base: 'lg', md: 'md' }}
|
|
1313
|
+
colorPalette="primary"
|
|
1314
|
+
defaultValue="tab1"
|
|
1315
|
+
>
|
|
1316
|
+
{/* Larger on mobile for touch, standard on desktop */}
|
|
1317
|
+
</Tabs.Root>
|
|
1318
|
+
|
|
1319
|
+
// Responsive fitted
|
|
1320
|
+
<Tabs.Root
|
|
1321
|
+
fitted={{ base: true, md: false }}
|
|
1322
|
+
colorPalette="primary"
|
|
1323
|
+
defaultValue="tab1"
|
|
1324
|
+
>
|
|
1325
|
+
{/* Full width on mobile, auto width on desktop */}
|
|
1326
|
+
</Tabs.Root>
|
|
1327
|
+
|
|
1328
|
+
// Handle overflow with scrolling
|
|
1329
|
+
<Box overflowX="auto">
|
|
1330
|
+
<Tabs.Root colorPalette="primary" defaultValue="tab1">
|
|
1331
|
+
<Tabs.List>
|
|
1332
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1333
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
1334
|
+
<Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
|
|
1335
|
+
<Tabs.Trigger value="tab4">Tab 4</Tabs.Trigger>
|
|
1336
|
+
<Tabs.Trigger value="tab5">Tab 5</Tabs.Trigger>
|
|
1337
|
+
<Tabs.Indicator />
|
|
1338
|
+
</Tabs.List>
|
|
1339
|
+
{/* Content */}
|
|
1340
|
+
</Tabs.Root>
|
|
1341
|
+
</Box>
|
|
1342
|
+
```
|
|
1343
|
+
|
|
1344
|
+
## Testing
|
|
1345
|
+
|
|
1346
|
+
When testing Tabs components:
|
|
1347
|
+
|
|
1348
|
+
```typescript
|
|
1349
|
+
import { render, screen } from '@testing-library/react';
|
|
1350
|
+
import userEvent from '@testing-library/user-event';
|
|
1351
|
+
|
|
1352
|
+
test('tabs switch content on click', async () => {
|
|
1353
|
+
render(
|
|
1354
|
+
<Tabs.Root colorPalette="primary" defaultValue="tab1">
|
|
1355
|
+
<Tabs.List>
|
|
1356
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1357
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
1358
|
+
<Tabs.Indicator />
|
|
1359
|
+
</Tabs.List>
|
|
1360
|
+
<Tabs.Content value="tab1">Content 1</Tabs.Content>
|
|
1361
|
+
<Tabs.Content value="tab2">Content 2</Tabs.Content>
|
|
1362
|
+
</Tabs.Root>
|
|
1363
|
+
);
|
|
1364
|
+
|
|
1365
|
+
expect(screen.getByText('Content 1')).toBeVisible();
|
|
1366
|
+
expect(screen.queryByText('Content 2')).not.toBeVisible();
|
|
1367
|
+
|
|
1368
|
+
await userEvent.click(screen.getByText('Tab 2'));
|
|
1369
|
+
|
|
1370
|
+
expect(screen.queryByText('Content 1')).not.toBeVisible();
|
|
1371
|
+
expect(screen.getByText('Content 2')).toBeVisible();
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
test('disabled tab cannot be activated', async () => {
|
|
1375
|
+
render(
|
|
1376
|
+
<Tabs.Root colorPalette="primary" defaultValue="tab1">
|
|
1377
|
+
<Tabs.List>
|
|
1378
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1379
|
+
<Tabs.Trigger value="tab2" disabled>Tab 2</Tabs.Trigger>
|
|
1380
|
+
<Tabs.Indicator />
|
|
1381
|
+
</Tabs.List>
|
|
1382
|
+
<Tabs.Content value="tab1">Content 1</Tabs.Content>
|
|
1383
|
+
<Tabs.Content value="tab2">Content 2</Tabs.Content>
|
|
1384
|
+
</Tabs.Root>
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
const disabledTab = screen.getByText('Tab 2');
|
|
1388
|
+
await userEvent.click(disabledTab);
|
|
1389
|
+
|
|
1390
|
+
expect(screen.getByText('Content 1')).toBeVisible();
|
|
1391
|
+
expect(screen.queryByText('Content 2')).not.toBeVisible();
|
|
1392
|
+
expect(disabledTab).toHaveAttribute('aria-disabled', 'true');
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
test('keyboard navigation with arrow keys', async () => {
|
|
1396
|
+
render(
|
|
1397
|
+
<Tabs.Root colorPalette="primary" defaultValue="tab1">
|
|
1398
|
+
<Tabs.List>
|
|
1399
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1400
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
1401
|
+
<Tabs.Trigger value="tab3">Tab 3</Tabs.Trigger>
|
|
1402
|
+
<Tabs.Indicator />
|
|
1403
|
+
</Tabs.List>
|
|
1404
|
+
<Tabs.Content value="tab1">Content 1</Tabs.Content>
|
|
1405
|
+
<Tabs.Content value="tab2">Content 2</Tabs.Content>
|
|
1406
|
+
<Tabs.Content value="tab3">Content 3</Tabs.Content>
|
|
1407
|
+
</Tabs.Root>
|
|
1408
|
+
);
|
|
1409
|
+
|
|
1410
|
+
const tab1 = screen.getByText('Tab 1');
|
|
1411
|
+
tab1.focus();
|
|
1412
|
+
|
|
1413
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
1414
|
+
expect(screen.getByText('Tab 2')).toHaveFocus();
|
|
1415
|
+
|
|
1416
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
1417
|
+
expect(screen.getByText('Tab 3')).toHaveFocus();
|
|
1418
|
+
|
|
1419
|
+
await userEvent.keyboard('{Home}');
|
|
1420
|
+
expect(screen.getByText('Tab 1')).toHaveFocus();
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
test('controlled tabs update on value change', async () => {
|
|
1424
|
+
const TestComponent = () => {
|
|
1425
|
+
const [value, setValue] = useState('tab1');
|
|
1426
|
+
return (
|
|
1427
|
+
<>
|
|
1428
|
+
<button onClick={() => setValue('tab2')}>Switch to Tab 2</button>
|
|
1429
|
+
<Tabs.Root
|
|
1430
|
+
colorPalette="primary"
|
|
1431
|
+
value={value}
|
|
1432
|
+
onValueChange={(d) => setValue(d.value)}
|
|
1433
|
+
>
|
|
1434
|
+
<Tabs.List>
|
|
1435
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1436
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
1437
|
+
<Tabs.Indicator />
|
|
1438
|
+
</Tabs.List>
|
|
1439
|
+
<Tabs.Content value="tab1">Content 1</Tabs.Content>
|
|
1440
|
+
<Tabs.Content value="tab2">Content 2</Tabs.Content>
|
|
1441
|
+
</Tabs.Root>
|
|
1442
|
+
</>
|
|
1443
|
+
);
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
render(<TestComponent />);
|
|
1447
|
+
await userEvent.click(screen.getByText('Switch to Tab 2'));
|
|
1448
|
+
expect(screen.getByText('Content 2')).toBeVisible();
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test('manual activation mode requires explicit activation', async () => {
|
|
1452
|
+
render(
|
|
1453
|
+
<Tabs.Root
|
|
1454
|
+
colorPalette="primary"
|
|
1455
|
+
activationMode="manual"
|
|
1456
|
+
defaultValue="tab1"
|
|
1457
|
+
>
|
|
1458
|
+
<Tabs.List>
|
|
1459
|
+
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
|
1460
|
+
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
|
|
1461
|
+
<Tabs.Indicator />
|
|
1462
|
+
</Tabs.List>
|
|
1463
|
+
<Tabs.Content value="tab1">Content 1</Tabs.Content>
|
|
1464
|
+
<Tabs.Content value="tab2">Content 2</Tabs.Content>
|
|
1465
|
+
</Tabs.Root>
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
const tab1 = screen.getByText('Tab 1');
|
|
1469
|
+
tab1.focus();
|
|
1470
|
+
|
|
1471
|
+
// Arrow key navigation doesn't activate tab in manual mode
|
|
1472
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
1473
|
+
expect(screen.getByText('Tab 2')).toHaveFocus();
|
|
1474
|
+
expect(screen.getByText('Content 1')).toBeVisible(); // Still tab 1 content
|
|
1475
|
+
|
|
1476
|
+
// Explicit Enter/Space activates tab
|
|
1477
|
+
await userEvent.keyboard('{Enter}');
|
|
1478
|
+
expect(screen.getByText('Content 2')).toBeVisible();
|
|
1479
|
+
});
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1482
|
+
## Related Components
|
|
1483
|
+
|
|
1484
|
+
- **Accordion**: For progressive disclosure of stacked content
|
|
1485
|
+
- **Menu**: For navigation or action menus with different interaction patterns
|
|
1486
|
+
- **Breadcrumbs**: For hierarchical navigation
|
|
1487
|
+
- **Stepper**: For sequential multi-step processes (not random access like tabs)
|
|
1488
|
+
- **SegmentedControl**: Alternative for 2-3 mutually exclusive options
|