@papernote/ui 1.9.2 → 1.10.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/dist/components/NotificationBell.d.ts +7 -1
- package/dist/components/NotificationBell.d.ts.map +1 -1
- package/dist/components/Tabs.d.ts +112 -1
- package/dist/components/Tabs.d.ts.map +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +120 -4
- package/dist/index.esm.js +437 -125
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +439 -123
- package/dist/index.js.map +1 -1
- package/dist/styles.css +23 -0
- package/package.json +1 -1
- package/src/components/NotificationBell.stories.tsx +71 -0
- package/src/components/NotificationBell.tsx +12 -4
- package/src/components/Tabs.stories.tsx +649 -6
- package/src/components/Tabs.tsx +613 -19
- package/src/components/index.ts +2 -2
package/src/components/Tabs.tsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback, createContext, useContext } from 'react';
|
|
2
|
+
import { X, Plus } from 'lucide-react';
|
|
3
|
+
import Badge from './Badge';
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Types & Interfaces
|
|
7
|
+
// =============================================================================
|
|
2
8
|
|
|
3
9
|
export interface Tab {
|
|
4
10
|
id: string;
|
|
@@ -6,6 +12,12 @@ export interface Tab {
|
|
|
6
12
|
icon?: React.ReactNode;
|
|
7
13
|
content: React.ReactNode;
|
|
8
14
|
disabled?: boolean;
|
|
15
|
+
/** Badge to display on the tab trigger */
|
|
16
|
+
badge?: number | string;
|
|
17
|
+
/** Badge variant color */
|
|
18
|
+
badgeVariant?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
|
19
|
+
/** Whether this individual tab can be closed (overrides global closeable) */
|
|
20
|
+
closeable?: boolean;
|
|
9
21
|
}
|
|
10
22
|
|
|
11
23
|
export interface TabsProps {
|
|
@@ -21,15 +33,409 @@ export interface TabsProps {
|
|
|
21
33
|
size?: 'sm' | 'md' | 'lg';
|
|
22
34
|
/** Called when tab changes (required for controlled mode) */
|
|
23
35
|
onChange?: (tabId: string) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Lazy loading: Only render active tab content (default: false)
|
|
38
|
+
* When true, inactive tabs are not rendered at all until visited.
|
|
39
|
+
*/
|
|
40
|
+
lazy?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Preserve state: Keep tabs mounted after first render (requires lazy=true)
|
|
43
|
+
* When true with lazy, tabs stay in DOM after being visited once.
|
|
44
|
+
*/
|
|
45
|
+
preserveState?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Global closeable: Allow all tabs to be closed (default: false)
|
|
48
|
+
* Individual tab.closeable can override this.
|
|
49
|
+
*/
|
|
50
|
+
closeable?: boolean;
|
|
51
|
+
/** Called when a tab close button is clicked */
|
|
52
|
+
onClose?: (tabId: string) => void;
|
|
53
|
+
/** Show an add button after the tabs (default: false) */
|
|
54
|
+
showAddButton?: boolean;
|
|
55
|
+
/** Called when add button is clicked */
|
|
56
|
+
onAdd?: () => void;
|
|
57
|
+
/** Label for the add button (default: 'Add tab') */
|
|
58
|
+
addButtonLabel?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// Compound Component Context
|
|
63
|
+
// =============================================================================
|
|
64
|
+
|
|
65
|
+
interface TabsContextValue {
|
|
66
|
+
activeTab: string;
|
|
67
|
+
setActiveTab: (value: string) => void;
|
|
68
|
+
variant: 'underline' | 'pill';
|
|
69
|
+
orientation: 'horizontal' | 'vertical';
|
|
70
|
+
size: 'sm' | 'md' | 'lg';
|
|
71
|
+
lazy: boolean;
|
|
72
|
+
preserveState: boolean;
|
|
73
|
+
visitedTabs: Set<string>;
|
|
74
|
+
registerTab: (value: string) => void;
|
|
75
|
+
unregisterTab: (value: string) => void;
|
|
76
|
+
tabValues: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
80
|
+
|
|
81
|
+
function useTabsContext() {
|
|
82
|
+
const context = useContext(TabsContext);
|
|
83
|
+
if (!context) {
|
|
84
|
+
throw new Error('Tabs compound components must be used within a TabsRoot component');
|
|
85
|
+
}
|
|
86
|
+
return context;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// =============================================================================
|
|
90
|
+
// Compound Components
|
|
91
|
+
// =============================================================================
|
|
92
|
+
|
|
93
|
+
export interface TabsRootProps {
|
|
94
|
+
children: React.ReactNode;
|
|
95
|
+
/** Default active tab value (uncontrolled mode) */
|
|
96
|
+
defaultValue?: string;
|
|
97
|
+
/** Active tab value (controlled mode) */
|
|
98
|
+
value?: string;
|
|
99
|
+
/** Called when tab changes */
|
|
100
|
+
onValueChange?: (value: string) => void;
|
|
101
|
+
/** Visual variant */
|
|
102
|
+
variant?: 'underline' | 'pill';
|
|
103
|
+
/** Layout orientation */
|
|
104
|
+
orientation?: 'horizontal' | 'vertical';
|
|
105
|
+
/** Size of tabs */
|
|
106
|
+
size?: 'sm' | 'md' | 'lg';
|
|
107
|
+
/** Only render active tab content */
|
|
108
|
+
lazy?: boolean;
|
|
109
|
+
/** Keep tabs mounted after first visit (requires lazy) */
|
|
110
|
+
preserveState?: boolean;
|
|
111
|
+
/** Additional class name */
|
|
112
|
+
className?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* TabsRoot - Root component for compound tabs pattern
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```tsx
|
|
120
|
+
* <TabsRoot defaultValue="tab1">
|
|
121
|
+
* <TabsList>
|
|
122
|
+
* <TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
|
123
|
+
* <TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
|
124
|
+
* </TabsList>
|
|
125
|
+
* <TabsContent value="tab1">Content 1</TabsContent>
|
|
126
|
+
* <TabsContent value="tab2">Content 2</TabsContent>
|
|
127
|
+
* </TabsRoot>
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export function TabsRoot({
|
|
131
|
+
children,
|
|
132
|
+
defaultValue,
|
|
133
|
+
value: controlledValue,
|
|
134
|
+
onValueChange,
|
|
135
|
+
variant = 'underline',
|
|
136
|
+
orientation = 'horizontal',
|
|
137
|
+
size = 'md',
|
|
138
|
+
lazy = false,
|
|
139
|
+
preserveState = false,
|
|
140
|
+
className = '',
|
|
141
|
+
}: TabsRootProps) {
|
|
142
|
+
const [tabValues, setTabValues] = useState<string[]>([]);
|
|
143
|
+
const [internalValue, setInternalValue] = useState(defaultValue || '');
|
|
144
|
+
const [visitedTabs, setVisitedTabs] = useState<Set<string>>(new Set(defaultValue ? [defaultValue] : []));
|
|
145
|
+
|
|
146
|
+
const isControlled = controlledValue !== undefined;
|
|
147
|
+
const activeTab = isControlled ? controlledValue : internalValue;
|
|
148
|
+
|
|
149
|
+
// Set initial value when tabs register
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!activeTab && tabValues.length > 0) {
|
|
152
|
+
const firstTab = tabValues[0];
|
|
153
|
+
if (isControlled) {
|
|
154
|
+
onValueChange?.(firstTab);
|
|
155
|
+
} else {
|
|
156
|
+
setInternalValue(firstTab);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}, [tabValues, activeTab, isControlled, onValueChange]);
|
|
160
|
+
|
|
161
|
+
// Track visited tabs
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (activeTab && preserveState) {
|
|
164
|
+
setVisitedTabs(prev => new Set(prev).add(activeTab));
|
|
165
|
+
}
|
|
166
|
+
}, [activeTab, preserveState]);
|
|
167
|
+
|
|
168
|
+
const setActiveTab = useCallback((value: string) => {
|
|
169
|
+
if (!isControlled) {
|
|
170
|
+
setInternalValue(value);
|
|
171
|
+
}
|
|
172
|
+
onValueChange?.(value);
|
|
173
|
+
}, [isControlled, onValueChange]);
|
|
174
|
+
|
|
175
|
+
const registerTab = useCallback((value: string) => {
|
|
176
|
+
setTabValues(prev => prev.includes(value) ? prev : [...prev, value]);
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
const unregisterTab = useCallback((value: string) => {
|
|
180
|
+
setTabValues(prev => prev.filter(v => v !== value));
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const contextValue: TabsContextValue = {
|
|
184
|
+
activeTab,
|
|
185
|
+
setActiveTab,
|
|
186
|
+
variant,
|
|
187
|
+
orientation,
|
|
188
|
+
size,
|
|
189
|
+
lazy,
|
|
190
|
+
preserveState,
|
|
191
|
+
visitedTabs,
|
|
192
|
+
registerTab,
|
|
193
|
+
unregisterTab,
|
|
194
|
+
tabValues,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Size-specific gap classes
|
|
198
|
+
const gapClasses = {
|
|
199
|
+
sm: orientation === 'vertical' ? 'gap-1.5' : 'gap-4',
|
|
200
|
+
md: orientation === 'vertical' ? 'gap-2' : 'gap-6',
|
|
201
|
+
lg: orientation === 'vertical' ? 'gap-3' : 'gap-8',
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<TabsContext.Provider value={contextValue}>
|
|
206
|
+
<div
|
|
207
|
+
className={`w-full ${orientation === 'vertical' ? `flex ${gapClasses[size]}` : ''} ${className}`}
|
|
208
|
+
>
|
|
209
|
+
{children}
|
|
210
|
+
</div>
|
|
211
|
+
</TabsContext.Provider>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface TabsListProps {
|
|
216
|
+
children: React.ReactNode;
|
|
217
|
+
/** Additional class name */
|
|
218
|
+
className?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* TabsList - Container for tab triggers
|
|
223
|
+
*/
|
|
224
|
+
export function TabsList({ children, className = '' }: TabsListProps) {
|
|
225
|
+
const { variant, orientation, size } = useTabsContext();
|
|
226
|
+
|
|
227
|
+
const sizeClasses = {
|
|
228
|
+
sm: {
|
|
229
|
+
gap: orientation === 'vertical' ? 'gap-1.5' : 'gap-4',
|
|
230
|
+
minWidth: orientation === 'vertical' ? 'min-w-[150px]' : '',
|
|
231
|
+
},
|
|
232
|
+
md: {
|
|
233
|
+
gap: orientation === 'vertical' ? 'gap-2' : 'gap-6',
|
|
234
|
+
minWidth: orientation === 'vertical' ? 'min-w-[200px]' : '',
|
|
235
|
+
},
|
|
236
|
+
lg: {
|
|
237
|
+
gap: orientation === 'vertical' ? 'gap-3' : 'gap-8',
|
|
238
|
+
minWidth: orientation === 'vertical' ? 'min-w-[250px]' : '',
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div
|
|
244
|
+
className={`
|
|
245
|
+
flex ${orientation === 'vertical' ? 'flex-col' : 'items-center'}
|
|
246
|
+
${variant === 'underline'
|
|
247
|
+
? orientation === 'vertical'
|
|
248
|
+
? `border-r border-paper-200 ${sizeClasses[size].gap} pr-6`
|
|
249
|
+
: `border-b border-paper-200 ${sizeClasses[size].gap}`
|
|
250
|
+
: `${sizeClasses[size].gap} p-1 bg-paper-50 rounded-lg`
|
|
251
|
+
}
|
|
252
|
+
${sizeClasses[size].minWidth}
|
|
253
|
+
${className}
|
|
254
|
+
`}
|
|
255
|
+
role="tablist"
|
|
256
|
+
aria-orientation={orientation}
|
|
257
|
+
>
|
|
258
|
+
{children}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface TabsTriggerProps {
|
|
264
|
+
children: React.ReactNode;
|
|
265
|
+
/** Unique value for this tab */
|
|
266
|
+
value: string;
|
|
267
|
+
/** Disabled state */
|
|
268
|
+
disabled?: boolean;
|
|
269
|
+
/** Icon to show before label */
|
|
270
|
+
icon?: React.ReactNode;
|
|
271
|
+
/** Badge to display */
|
|
272
|
+
badge?: number | string;
|
|
273
|
+
/** Badge variant */
|
|
274
|
+
badgeVariant?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
|
275
|
+
/** Additional class name */
|
|
276
|
+
className?: string;
|
|
24
277
|
}
|
|
25
278
|
|
|
26
|
-
|
|
279
|
+
/**
|
|
280
|
+
* TabsTrigger - Individual tab button
|
|
281
|
+
*/
|
|
282
|
+
export function TabsTrigger({
|
|
283
|
+
children,
|
|
284
|
+
value,
|
|
285
|
+
disabled = false,
|
|
286
|
+
icon,
|
|
287
|
+
badge,
|
|
288
|
+
badgeVariant = 'info',
|
|
289
|
+
className = '',
|
|
290
|
+
}: TabsTriggerProps) {
|
|
291
|
+
const { activeTab, setActiveTab, variant, orientation, size, registerTab, unregisterTab } = useTabsContext();
|
|
292
|
+
const isActive = activeTab === value;
|
|
293
|
+
|
|
294
|
+
// Register this tab on mount
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
registerTab(value);
|
|
297
|
+
return () => unregisterTab(value);
|
|
298
|
+
}, [value, registerTab, unregisterTab]);
|
|
299
|
+
|
|
300
|
+
const sizeClasses = {
|
|
301
|
+
sm: { padding: 'px-3 py-1.5', text: 'text-xs', icon: 'h-3.5 w-3.5', badgeSize: 'sm' as const },
|
|
302
|
+
md: { padding: 'px-4 py-2.5', text: 'text-sm', icon: 'h-4 w-4', badgeSize: 'sm' as const },
|
|
303
|
+
lg: { padding: 'px-5 py-3', text: 'text-base', icon: 'h-5 w-5', badgeSize: 'md' as const },
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<button
|
|
308
|
+
role="tab"
|
|
309
|
+
aria-selected={isActive}
|
|
310
|
+
aria-controls={`panel-${value}`}
|
|
311
|
+
aria-disabled={disabled}
|
|
312
|
+
tabIndex={isActive ? 0 : -1}
|
|
313
|
+
disabled={disabled}
|
|
314
|
+
onClick={() => !disabled && setActiveTab(value)}
|
|
315
|
+
className={`
|
|
316
|
+
flex items-center gap-2 ${sizeClasses[size].padding} ${sizeClasses[size].text} font-medium transition-all duration-200
|
|
317
|
+
${orientation === 'vertical' ? 'w-full justify-start' : ''}
|
|
318
|
+
${
|
|
319
|
+
variant === 'underline'
|
|
320
|
+
? isActive
|
|
321
|
+
? orientation === 'vertical'
|
|
322
|
+
? 'text-accent-900 border-r-2 border-accent-500 -mr-[1px]'
|
|
323
|
+
: 'text-accent-900 border-b-2 border-accent-500 -mb-[1px]'
|
|
324
|
+
: orientation === 'vertical'
|
|
325
|
+
? 'text-ink-600 hover:text-ink-900 border-r-2 border-transparent'
|
|
326
|
+
: 'text-ink-600 hover:text-ink-900 border-b-2 border-transparent'
|
|
327
|
+
: isActive
|
|
328
|
+
? 'bg-white text-accent-900 rounded-md shadow-xs'
|
|
329
|
+
: 'text-ink-600 hover:text-ink-900 hover:bg-white/50 rounded-md'
|
|
330
|
+
}
|
|
331
|
+
${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
|
|
332
|
+
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-1
|
|
333
|
+
${className}
|
|
334
|
+
`}
|
|
335
|
+
>
|
|
336
|
+
{icon && <span className={`flex-shrink-0 ${sizeClasses[size].icon}`}>{icon}</span>}
|
|
337
|
+
<span>{children}</span>
|
|
338
|
+
{badge !== undefined && (
|
|
339
|
+
<Badge variant={badgeVariant} size={sizeClasses[size].badgeSize}>
|
|
340
|
+
{badge}
|
|
341
|
+
</Badge>
|
|
342
|
+
)}
|
|
343
|
+
</button>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface TabsContentProps {
|
|
348
|
+
children: React.ReactNode;
|
|
349
|
+
/** Value matching the corresponding TabsTrigger */
|
|
350
|
+
value: string;
|
|
351
|
+
/** Additional class name */
|
|
352
|
+
className?: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* TabsContent - Content panel for a tab
|
|
357
|
+
*/
|
|
358
|
+
export function TabsContent({ children, value, className = '' }: TabsContentProps) {
|
|
359
|
+
const { activeTab, lazy, preserveState, visitedTabs, orientation, size } = useTabsContext();
|
|
360
|
+
const isActive = activeTab === value;
|
|
361
|
+
|
|
362
|
+
// Determine if content should be rendered
|
|
363
|
+
const shouldRender = !lazy || isActive || (preserveState && visitedTabs.has(value));
|
|
364
|
+
|
|
365
|
+
if (!shouldRender) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const spacingClasses = {
|
|
370
|
+
sm: orientation === 'vertical' ? '' : 'mt-4',
|
|
371
|
+
md: orientation === 'vertical' ? '' : 'mt-6',
|
|
372
|
+
lg: orientation === 'vertical' ? '' : 'mt-8',
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div
|
|
377
|
+
id={`panel-${value}`}
|
|
378
|
+
role="tabpanel"
|
|
379
|
+
aria-labelledby={`tab-${value}`}
|
|
380
|
+
hidden={!isActive}
|
|
381
|
+
tabIndex={0}
|
|
382
|
+
className={`
|
|
383
|
+
${orientation === 'vertical' ? 'flex-1' : spacingClasses[size]}
|
|
384
|
+
${isActive ? 'animate-fade-in focus:outline-none' : 'hidden'}
|
|
385
|
+
${className}
|
|
386
|
+
`}
|
|
387
|
+
>
|
|
388
|
+
{children}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// =============================================================================
|
|
394
|
+
// Data-Driven Component (Original API)
|
|
395
|
+
// =============================================================================
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Tabs - Data-driven tabs component
|
|
399
|
+
*
|
|
400
|
+
* Use this for simple use cases where tabs are defined as an array.
|
|
401
|
+
* For more complex layouts, use the compound components (TabsRoot, TabsList, TabsTrigger, TabsContent).
|
|
402
|
+
*/
|
|
403
|
+
export default function Tabs({
|
|
404
|
+
tabs,
|
|
405
|
+
activeTab: controlledActiveTab,
|
|
406
|
+
defaultTab,
|
|
407
|
+
variant = 'underline',
|
|
408
|
+
orientation = 'horizontal',
|
|
409
|
+
size = 'md',
|
|
410
|
+
onChange,
|
|
411
|
+
lazy = false,
|
|
412
|
+
preserveState = false,
|
|
413
|
+
closeable = false,
|
|
414
|
+
onClose,
|
|
415
|
+
showAddButton = false,
|
|
416
|
+
onAdd,
|
|
417
|
+
addButtonLabel = 'Add tab',
|
|
418
|
+
}: TabsProps) {
|
|
27
419
|
const [internalActiveTab, setInternalActiveTab] = useState(defaultTab || tabs[0]?.id);
|
|
420
|
+
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
|
|
421
|
+
const [visitedTabs, setVisitedTabs] = useState<Set<string>>(new Set([defaultTab || tabs[0]?.id]));
|
|
422
|
+
const tabListRef = useRef<HTMLDivElement>(null);
|
|
423
|
+
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
28
424
|
|
|
29
425
|
// Controlled mode: use activeTab prop, Uncontrolled mode: use internal state
|
|
30
426
|
const isControlled = controlledActiveTab !== undefined;
|
|
31
427
|
const activeTab = isControlled ? controlledActiveTab : internalActiveTab;
|
|
32
428
|
|
|
429
|
+
// Track visited tabs for preserveState
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (activeTab && preserveState) {
|
|
432
|
+
setVisitedTabs(prev => new Set(prev).add(activeTab));
|
|
433
|
+
}
|
|
434
|
+
}, [activeTab, preserveState]);
|
|
435
|
+
|
|
436
|
+
// Get enabled tab indices for keyboard navigation
|
|
437
|
+
const enabledIndices = tabs.map((tab, index) => tab.disabled ? -1 : index).filter(i => i !== -1);
|
|
438
|
+
|
|
33
439
|
// Ensure the activeTab exists in the current tabs array
|
|
34
440
|
// This handles the case where tabs array reference changes at the same time as activeTab
|
|
35
441
|
useEffect(() => {
|
|
@@ -44,12 +450,104 @@ export default function Tabs({ tabs, activeTab: controlledActiveTab, defaultTab,
|
|
|
44
450
|
}
|
|
45
451
|
}, [tabs, activeTab, isControlled, onChange]);
|
|
46
452
|
|
|
47
|
-
const handleTabChange = (tabId: string) => {
|
|
453
|
+
const handleTabChange = useCallback((tabId: string) => {
|
|
48
454
|
if (!isControlled) {
|
|
49
455
|
setInternalActiveTab(tabId);
|
|
50
456
|
}
|
|
51
457
|
onChange?.(tabId);
|
|
52
|
-
};
|
|
458
|
+
}, [isControlled, onChange]);
|
|
459
|
+
|
|
460
|
+
// Handle tab close
|
|
461
|
+
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
|
|
462
|
+
e.stopPropagation();
|
|
463
|
+
onClose?.(tabId);
|
|
464
|
+
}, [onClose]);
|
|
465
|
+
|
|
466
|
+
// Keyboard navigation handler
|
|
467
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
468
|
+
const currentIndex = focusedIndex >= 0 ? focusedIndex : tabs.findIndex(t => t.id === activeTab);
|
|
469
|
+
const currentEnabledPosition = enabledIndices.indexOf(currentIndex);
|
|
470
|
+
|
|
471
|
+
let nextIndex = -1;
|
|
472
|
+
let shouldPreventDefault = true;
|
|
473
|
+
|
|
474
|
+
// Determine navigation keys based on orientation
|
|
475
|
+
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
|
|
476
|
+
const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
|
|
477
|
+
|
|
478
|
+
switch (e.key) {
|
|
479
|
+
case prevKey:
|
|
480
|
+
// Move to previous enabled tab
|
|
481
|
+
if (currentEnabledPosition > 0) {
|
|
482
|
+
nextIndex = enabledIndices[currentEnabledPosition - 1];
|
|
483
|
+
} else {
|
|
484
|
+
// Wrap to last enabled tab
|
|
485
|
+
nextIndex = enabledIndices[enabledIndices.length - 1];
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
|
|
489
|
+
case nextKey:
|
|
490
|
+
// Move to next enabled tab
|
|
491
|
+
if (currentEnabledPosition < enabledIndices.length - 1) {
|
|
492
|
+
nextIndex = enabledIndices[currentEnabledPosition + 1];
|
|
493
|
+
} else {
|
|
494
|
+
// Wrap to first enabled tab
|
|
495
|
+
nextIndex = enabledIndices[0];
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
|
|
499
|
+
case 'Home':
|
|
500
|
+
// Move to first enabled tab
|
|
501
|
+
nextIndex = enabledIndices[0];
|
|
502
|
+
break;
|
|
503
|
+
|
|
504
|
+
case 'End':
|
|
505
|
+
// Move to last enabled tab
|
|
506
|
+
nextIndex = enabledIndices[enabledIndices.length - 1];
|
|
507
|
+
break;
|
|
508
|
+
|
|
509
|
+
case 'Enter':
|
|
510
|
+
case ' ':
|
|
511
|
+
// Activate focused tab
|
|
512
|
+
if (focusedIndex >= 0 && !tabs[focusedIndex]?.disabled) {
|
|
513
|
+
handleTabChange(tabs[focusedIndex].id);
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
|
|
517
|
+
case 'Delete':
|
|
518
|
+
case 'Backspace':
|
|
519
|
+
// Close focused tab if closeable
|
|
520
|
+
if (focusedIndex >= 0) {
|
|
521
|
+
const tab = tabs[focusedIndex];
|
|
522
|
+
const isTabCloseable = tab.closeable !== undefined ? tab.closeable : closeable;
|
|
523
|
+
if (isTabCloseable && !tab.disabled && onClose) {
|
|
524
|
+
onClose(tab.id);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
break;
|
|
528
|
+
|
|
529
|
+
default:
|
|
530
|
+
shouldPreventDefault = false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (shouldPreventDefault) {
|
|
534
|
+
e.preventDefault();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (nextIndex >= 0 && nextIndex !== currentIndex) {
|
|
538
|
+
setFocusedIndex(nextIndex);
|
|
539
|
+
tabRefs.current[nextIndex]?.focus();
|
|
540
|
+
}
|
|
541
|
+
}, [focusedIndex, tabs, activeTab, enabledIndices, orientation, handleTabChange, closeable, onClose]);
|
|
542
|
+
|
|
543
|
+
// Handle focus events
|
|
544
|
+
const handleFocus = useCallback((index: number) => {
|
|
545
|
+
setFocusedIndex(index);
|
|
546
|
+
}, []);
|
|
547
|
+
|
|
548
|
+
const handleBlur = useCallback(() => {
|
|
549
|
+
setFocusedIndex(-1);
|
|
550
|
+
}, []);
|
|
53
551
|
|
|
54
552
|
// Size-specific classes
|
|
55
553
|
const sizeClasses = {
|
|
@@ -57,34 +555,61 @@ export default function Tabs({ tabs, activeTab: controlledActiveTab, defaultTab,
|
|
|
57
555
|
padding: 'px-3 py-1.5',
|
|
58
556
|
text: 'text-xs',
|
|
59
557
|
icon: 'h-3.5 w-3.5',
|
|
558
|
+
closeIcon: 'h-3 w-3',
|
|
60
559
|
gap: orientation === 'vertical' ? 'gap-1.5' : 'gap-4',
|
|
61
560
|
minWidth: orientation === 'vertical' ? 'min-w-[150px]' : '',
|
|
62
561
|
spacing: orientation === 'vertical' ? 'mt-4' : 'mt-4',
|
|
562
|
+
badgeSize: 'sm' as const,
|
|
563
|
+
addPadding: 'px-2 py-1.5',
|
|
63
564
|
},
|
|
64
565
|
md: {
|
|
65
566
|
padding: 'px-4 py-2.5',
|
|
66
567
|
text: 'text-sm',
|
|
67
568
|
icon: 'h-4 w-4',
|
|
569
|
+
closeIcon: 'h-3.5 w-3.5',
|
|
68
570
|
gap: orientation === 'vertical' ? 'gap-2' : 'gap-6',
|
|
69
571
|
minWidth: orientation === 'vertical' ? 'min-w-[200px]' : '',
|
|
70
572
|
spacing: orientation === 'vertical' ? 'mt-6' : 'mt-6',
|
|
573
|
+
badgeSize: 'sm' as const,
|
|
574
|
+
addPadding: 'px-3 py-2.5',
|
|
71
575
|
},
|
|
72
576
|
lg: {
|
|
73
577
|
padding: 'px-5 py-3',
|
|
74
578
|
text: 'text-base',
|
|
75
579
|
icon: 'h-5 w-5',
|
|
580
|
+
closeIcon: 'h-4 w-4',
|
|
76
581
|
gap: orientation === 'vertical' ? 'gap-3' : 'gap-8',
|
|
77
582
|
minWidth: orientation === 'vertical' ? 'min-w-[250px]' : '',
|
|
78
583
|
spacing: orientation === 'vertical' ? 'mt-8' : 'mt-8',
|
|
584
|
+
badgeSize: 'md' as const,
|
|
585
|
+
addPadding: 'px-4 py-3',
|
|
79
586
|
},
|
|
80
587
|
};
|
|
81
588
|
|
|
589
|
+
// Determine which tabs should be rendered
|
|
590
|
+
const shouldRenderContent = useCallback((tabId: string) => {
|
|
591
|
+
if (!lazy) {
|
|
592
|
+
// Not lazy: render all tabs
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
if (tabId === activeTab) {
|
|
596
|
+
// Always render active tab
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
if (preserveState && visitedTabs.has(tabId)) {
|
|
600
|
+
// Preserve state: render previously visited tabs
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}, [lazy, activeTab, preserveState, visitedTabs]);
|
|
605
|
+
|
|
82
606
|
return (
|
|
83
607
|
<div className={`w-full ${orientation === 'vertical' ? `flex ${sizeClasses[size].gap}` : ''}`}>
|
|
84
608
|
{/* Tab Headers */}
|
|
85
609
|
<div
|
|
610
|
+
ref={tabListRef}
|
|
86
611
|
className={`
|
|
87
|
-
flex ${orientation === 'vertical' ? 'flex-col' : ''}
|
|
612
|
+
flex ${orientation === 'vertical' ? 'flex-col' : 'items-center'}
|
|
88
613
|
${variant === 'underline'
|
|
89
614
|
? orientation === 'vertical'
|
|
90
615
|
? `border-r border-paper-200 ${sizeClasses[size].gap} pr-6`
|
|
@@ -94,18 +619,27 @@ export default function Tabs({ tabs, activeTab: controlledActiveTab, defaultTab,
|
|
|
94
619
|
${sizeClasses[size].minWidth}
|
|
95
620
|
`}
|
|
96
621
|
role="tablist"
|
|
622
|
+
aria-orientation={orientation}
|
|
623
|
+
onKeyDown={handleKeyDown}
|
|
97
624
|
>
|
|
98
|
-
{tabs.map((tab) => {
|
|
625
|
+
{tabs.map((tab, index) => {
|
|
99
626
|
const isActive = activeTab === tab.id;
|
|
627
|
+
const isTabCloseable = tab.closeable !== undefined ? tab.closeable : closeable;
|
|
100
628
|
|
|
101
629
|
return (
|
|
102
630
|
<button
|
|
103
631
|
key={tab.id}
|
|
632
|
+
ref={(el) => { tabRefs.current[index] = el; }}
|
|
633
|
+
id={`tab-${tab.id}`}
|
|
104
634
|
role="tab"
|
|
105
635
|
aria-selected={isActive}
|
|
106
636
|
aria-controls={`panel-${tab.id}`}
|
|
637
|
+
aria-disabled={tab.disabled}
|
|
638
|
+
tabIndex={isActive ? 0 : -1}
|
|
107
639
|
disabled={tab.disabled}
|
|
108
640
|
onClick={() => !tab.disabled && handleTabChange(tab.id)}
|
|
641
|
+
onFocus={() => handleFocus(index)}
|
|
642
|
+
onBlur={handleBlur}
|
|
109
643
|
className={`
|
|
110
644
|
flex items-center gap-2 ${sizeClasses[size].padding} ${sizeClasses[size].text} font-medium transition-all duration-200
|
|
111
645
|
${orientation === 'vertical' ? 'w-full justify-start' : ''}
|
|
@@ -123,29 +657,89 @@ export default function Tabs({ tabs, activeTab: controlledActiveTab, defaultTab,
|
|
|
123
657
|
: 'text-ink-600 hover:text-ink-900 hover:bg-white/50 rounded-md'
|
|
124
658
|
}
|
|
125
659
|
${tab.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
|
|
660
|
+
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-1
|
|
661
|
+
group
|
|
126
662
|
`}
|
|
127
663
|
>
|
|
128
664
|
{tab.icon && <span className={`flex-shrink-0 ${sizeClasses[size].icon}`}>{tab.icon}</span>}
|
|
129
|
-
<span>{tab.label}</span>
|
|
665
|
+
<span className={isTabCloseable ? 'mr-1' : ''}>{tab.label}</span>
|
|
666
|
+
{tab.badge !== undefined && (
|
|
667
|
+
<Badge
|
|
668
|
+
variant={tab.badgeVariant || 'info'}
|
|
669
|
+
size={sizeClasses[size].badgeSize}
|
|
670
|
+
>
|
|
671
|
+
{tab.badge}
|
|
672
|
+
</Badge>
|
|
673
|
+
)}
|
|
674
|
+
{isTabCloseable && onClose && (
|
|
675
|
+
<span
|
|
676
|
+
role="button"
|
|
677
|
+
aria-label={`Close ${tab.label}`}
|
|
678
|
+
onClick={(e) => handleClose(e, tab.id)}
|
|
679
|
+
className={`
|
|
680
|
+
flex-shrink-0 p-0.5 rounded
|
|
681
|
+
text-ink-400 hover:text-ink-700 hover:bg-paper-200
|
|
682
|
+
opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100
|
|
683
|
+
${isActive ? 'opacity-100' : ''}
|
|
684
|
+
transition-opacity duration-150
|
|
685
|
+
`}
|
|
686
|
+
>
|
|
687
|
+
<X className={sizeClasses[size].closeIcon} />
|
|
688
|
+
</span>
|
|
689
|
+
)}
|
|
130
690
|
</button>
|
|
131
691
|
);
|
|
132
692
|
})}
|
|
693
|
+
|
|
694
|
+
{/* Add Tab Button */}
|
|
695
|
+
{showAddButton && onAdd && (
|
|
696
|
+
<button
|
|
697
|
+
type="button"
|
|
698
|
+
onClick={onAdd}
|
|
699
|
+
aria-label={addButtonLabel}
|
|
700
|
+
title={addButtonLabel}
|
|
701
|
+
className={`
|
|
702
|
+
flex items-center justify-center ${sizeClasses[size].addPadding}
|
|
703
|
+
text-ink-500 hover:text-ink-700 hover:bg-paper-100
|
|
704
|
+
rounded transition-colors duration-150
|
|
705
|
+
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-1
|
|
706
|
+
${variant === 'underline'
|
|
707
|
+
? orientation === 'vertical'
|
|
708
|
+
? 'border-r-2 border-transparent'
|
|
709
|
+
: 'border-b-2 border-transparent -mb-[1px]'
|
|
710
|
+
: ''
|
|
711
|
+
}
|
|
712
|
+
`}
|
|
713
|
+
>
|
|
714
|
+
<Plus className={sizeClasses[size].icon} />
|
|
715
|
+
</button>
|
|
716
|
+
)}
|
|
133
717
|
</div>
|
|
134
718
|
|
|
135
719
|
{/* Tab Content */}
|
|
136
720
|
<div className={`${orientation === 'vertical' ? 'flex-1' : sizeClasses[size].spacing}`}>
|
|
137
|
-
{tabs.map((tab) =>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
721
|
+
{tabs.map((tab) => {
|
|
722
|
+
const isActive = activeTab === tab.id;
|
|
723
|
+
const shouldRender = shouldRenderContent(tab.id);
|
|
724
|
+
|
|
725
|
+
if (!shouldRender) {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return (
|
|
730
|
+
<div
|
|
731
|
+
key={tab.id}
|
|
732
|
+
id={`panel-${tab.id}`}
|
|
733
|
+
role="tabpanel"
|
|
734
|
+
aria-labelledby={`tab-${tab.id}`}
|
|
735
|
+
hidden={!isActive}
|
|
736
|
+
tabIndex={0}
|
|
737
|
+
className={isActive ? 'animate-fade-in focus:outline-none' : 'hidden'}
|
|
738
|
+
>
|
|
739
|
+
{tab.content}
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
})}
|
|
149
743
|
</div>
|
|
150
744
|
</div>
|
|
151
745
|
);
|