@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.
@@ -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
- export default function Tabs({ tabs, activeTab: controlledActiveTab, defaultTab, variant = 'underline', orientation = 'horizontal', size = 'md', onChange }: TabsProps) {
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
- <div
139
- key={tab.id}
140
- id={`panel-${tab.id}`}
141
- role="tabpanel"
142
- aria-labelledby={tab.id}
143
- hidden={activeTab !== tab.id}
144
- className={activeTab === tab.id ? 'animate-fade-in' : ''}
145
- >
146
- {activeTab === tab.id && tab.content}
147
- </div>
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
  );