ai-design-system 0.1.14 → 0.1.16

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.
@@ -0,0 +1,122 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { DocumentTabBar } from './DocumentTabBar'
3
+ import type { DocumentTabBarProps } from './DocumentTabBar'
4
+
5
+ /**
6
+ * DocumentTabBar composite - VS Code-style document tabs
7
+ *
8
+ * Displays open documents as closeable tabs with dirty indicators.
9
+ * Used by multi-document editors to allow users to switch between
10
+ * and manage open files.
11
+ */
12
+ const meta = {
13
+ title: 'Composites/DocumentTabBar',
14
+ component: DocumentTabBar,
15
+ parameters: {
16
+ layout: 'fullscreen',
17
+ },
18
+ tags: ['autodocs'],
19
+ } satisfies Meta<typeof DocumentTabBar>
20
+
21
+ export default meta
22
+ type Story = StoryObj<typeof meta>
23
+
24
+ /**
25
+ * Default - Single tab
26
+ */
27
+ export const Default: Story = {
28
+ args: {
29
+ tabs: [
30
+ {
31
+ id: 'doc-1',
32
+ name: 'Untitled',
33
+ isDirty: false,
34
+ format: 'json',
35
+ lastModified: Date.now(),
36
+ },
37
+ ],
38
+ activeTabId: 'doc-1',
39
+ },
40
+ }
41
+
42
+ /**
43
+ * Multiple tabs - Mix of clean and dirty
44
+ */
45
+ export const MultipleTabs: Story = {
46
+ args: {
47
+ tabs: [
48
+ {
49
+ id: 'doc-1',
50
+ name: 'Introduction.md',
51
+ isDirty: false,
52
+ format: 'markdown',
53
+ lastModified: Date.now() - 3600000,
54
+ },
55
+ {
56
+ id: 'doc-2',
57
+ name: 'Workflow.json',
58
+ isDirty: true,
59
+ format: 'json',
60
+ lastModified: Date.now() - 1800000,
61
+ },
62
+ {
63
+ id: 'doc-3',
64
+ name: 'Configuration.md',
65
+ isDirty: true,
66
+ format: 'markdown',
67
+ lastModified: Date.now(),
68
+ },
69
+ ],
70
+ activeTabId: 'doc-2',
71
+ },
72
+ }
73
+
74
+ /**
75
+ * Many tabs - Demonstrates horizontal scrolling
76
+ */
77
+ export const ManyTabs: Story = {
78
+ args: {
79
+ tabs: Array.from({ length: 10 }).map((_, i) => ({
80
+ id: `doc-${i}`,
81
+ name: `Document-${i + 1}.md`,
82
+ isDirty: Math.random() > 0.6,
83
+ format: 'markdown' as const,
84
+ lastModified: Date.now() - i * 3600000,
85
+ })),
86
+ activeTabId: 'doc-5',
87
+ },
88
+ }
89
+
90
+ /**
91
+ * Long names - Tab text truncation
92
+ */
93
+ export const LongTabNames: Story = {
94
+ args: {
95
+ tabs: [
96
+ {
97
+ id: 'doc-1',
98
+ name: 'VeryLongDocumentNameThatShouldBeTruncated.md',
99
+ isDirty: true,
100
+ format: 'markdown',
101
+ lastModified: Date.now(),
102
+ },
103
+ {
104
+ id: 'doc-2',
105
+ name: 'AnotherDocumentWithAnExtremelyLongNameForTesting.json',
106
+ isDirty: false,
107
+ format: 'json',
108
+ lastModified: Date.now(),
109
+ },
110
+ ],
111
+ activeTabId: 'doc-1',
112
+ },
113
+ }
114
+
115
+ /**
116
+ * Empty state - No tabs
117
+ */
118
+ export const EmptyTabs: Story = {
119
+ args: {
120
+ tabs: [],
121
+ },
122
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * DocumentTabBar Composite
3
+ *
4
+ * VS Code-style tab bar for multi-document editors.
5
+ * Displays open documents as closeable tabs with dirty indicators.
6
+ */
7
+
8
+ import React from 'react'
9
+ import { Tabs, TabsList, TabsTrigger, Button, ScrollArea } from '@/components/primitives'
10
+ import { X, Circle } from 'lucide-react'
11
+ import { cn } from '@/lib/utils'
12
+ import type { DocumentFile } from '@/types/ai-editor'
13
+
14
+ /**
15
+ * Props for DocumentTabBar composite
16
+ */
17
+ export interface DocumentTabBarProps {
18
+ /**
19
+ * Array of open documents
20
+ */
21
+ tabs: DocumentFile[]
22
+
23
+ /**
24
+ * ID of currently active document
25
+ */
26
+ activeTabId?: string
27
+
28
+ /**
29
+ * Callback when tab is selected
30
+ */
31
+ onTabSelect?: (documentId: string) => void
32
+
33
+ /**
34
+ * Callback when tab close button is clicked
35
+ */
36
+ onTabClose?: (documentId: string) => void
37
+
38
+ /**
39
+ * Additional CSS classes
40
+ */
41
+ className?: string
42
+ }
43
+
44
+ /**
45
+ * DocumentTabBar - VS Code-style document tabs
46
+ *
47
+ * Renders a horizontal tab bar for switching between open documents.
48
+ * Each tab shows:
49
+ * - Document name
50
+ * - Dirty indicator (when isDirty=true)
51
+ * - Close button (X)
52
+ *
53
+ * Features:
54
+ * - Scrollable when tabs overflow horizontally
55
+ * - Accessible tab navigation via Radix UI Tabs
56
+ * - Close button to remove tabs
57
+ * - Dirty state indicator
58
+ */
59
+ export const DocumentTabBar = React.memo<DocumentTabBarProps>(
60
+ ({ tabs, activeTabId, onTabSelect, onTabClose, className }) => {
61
+ if (tabs.length === 0) {
62
+ return null
63
+ }
64
+
65
+ return (
66
+ <div
67
+ className={cn(
68
+ 'flex items-center border-b border-border bg-background',
69
+ className
70
+ )}
71
+ data-slot="document-tab-bar"
72
+ >
73
+ <ScrollArea orientation="horizontal" className="flex-1">
74
+ <Tabs
75
+ value={activeTabId || tabs[0]?.id}
76
+ onValueChange={onTabSelect}
77
+ className="h-auto flex-1"
78
+ >
79
+ <TabsList className="h-10 rounded-none border-none bg-transparent p-0 w-full justify-start gap-0">
80
+ {tabs.map((tab) => (
81
+ <div
82
+ key={tab.id}
83
+ className="flex items-center border-r border-border/50 last:border-r-0"
84
+ >
85
+ <TabsTrigger
86
+ value={tab.id}
87
+ className={cn(
88
+ 'data-[state=inactive]:bg-muted/20 data-[state=inactive]:text-muted-foreground',
89
+ 'rounded-none border-b-2 border-transparent data-[state=active]:border-primary',
90
+ 'px-3 py-2 text-sm font-medium whitespace-nowrap',
91
+ 'flex items-center gap-2 h-10',
92
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0',
93
+ 'transition-colors'
94
+ )}
95
+ >
96
+ <div className="flex items-center gap-2">
97
+ {/* Dirty indicator */}
98
+ {tab.isDirty && (
99
+ <Circle
100
+ className="size-2 fill-primary text-primary flex-shrink-0"
101
+ aria-label="unsaved changes"
102
+ />
103
+ )}
104
+ {/* Tab name */}
105
+ <span className="truncate max-w-[200px]">{tab.name}</span>
106
+ </div>
107
+ </TabsTrigger>
108
+
109
+ {/* Close button */}
110
+ <Button
111
+ variant="ghost"
112
+ size="icon-sm"
113
+ onClick={(e) => {
114
+ e.stopPropagation()
115
+ onTabClose?.(tab.id)
116
+ }}
117
+ className={cn(
118
+ 'h-8 w-8 mr-0.5',
119
+ 'hover:bg-destructive/10 hover:text-destructive',
120
+ 'focus-visible:ring-2 focus-visible:ring-ring',
121
+ 'transition-colors'
122
+ )}
123
+ aria-label={`Close ${tab.name}`}
124
+ >
125
+ <X className="size-3" />
126
+ </Button>
127
+ </div>
128
+ ))}
129
+ </TabsList>
130
+ </Tabs>
131
+ </ScrollArea>
132
+ </div>
133
+ )
134
+ }
135
+ )
136
+
137
+ DocumentTabBar.displayName = 'DocumentTabBar'
@@ -0,0 +1,2 @@
1
+ export { DocumentTabBar } from './DocumentTabBar'
2
+ export type { DocumentTabBarProps } from './DocumentTabBar'
@@ -49,6 +49,10 @@ export type { DocumentEditorProps } from '@/types/ai-editor'
49
49
  export { ModeToggle } from './ModeToggle'
50
50
  export type { ModeToggleProps } from './ModeToggle'
51
51
 
52
+ // DocumentTabBar Composite
53
+ export { DocumentTabBar } from './DocumentTabBar'
54
+ export type { DocumentTabBarProps } from './DocumentTabBar'
55
+
52
56
  // ThemeSelector Composite
53
57
  export { ThemeSelector } from './ThemeSelector'
54
58
  export type { ThemeSelectorProps, Theme } from './ThemeSelector'
@@ -11,17 +11,17 @@ import type { Annotation } from '@/types/ai-editor'
11
11
  import { AIDocEditor } from './AIDocEditor'
12
12
  import { currentUser, sampleAnnotations, sampleContent } from './AIDocEditor.mocks'
13
13
 
14
- const meta: Meta<typeof AIDocEditor> = {
14
+ const meta = {
15
15
  title: 'Features/AIDocEditor/Behaviors',
16
16
  component: AIDocEditor,
17
17
  tags: ['test'],
18
18
  parameters: {
19
19
  layout: 'fullscreen',
20
20
  },
21
- }
21
+ } satisfies Meta<typeof AIDocEditor>
22
22
 
23
23
  export default meta
24
- type Story = StoryObj<typeof AIDocEditor>
24
+ type Story = StoryObj<typeof meta>
25
25
 
26
26
  // ============================================================================
27
27
  // CRITICAL PRIORITY TESTS (100% coverage required)
@@ -449,3 +449,208 @@ export const MultipleAnnotationsRenderCorrectly: Story = {
449
449
  expect(canvas.getByText(/Document Review Example/i)).toBeVisible()
450
450
  },
451
451
  }
452
+
453
+ // ============================================================================
454
+ // MULTI-TAB MODE TESTS
455
+ // ============================================================================
456
+
457
+ /**
458
+ * Test: Tab selection switches document
459
+ * Verifies that clicking a tab switches to the correct document
460
+ */
461
+ export const TabSelectionSwitchesDocument: Story = {
462
+ args: {
463
+ documents: [
464
+ {
465
+ file: { id: 'doc-1', name: 'Doc1.md', isDirty: false, format: 'markdown', lastModified: Date.now() },
466
+ content: sampleContent,
467
+ annotations: sampleAnnotations,
468
+ },
469
+ {
470
+ file: { id: 'doc-2', name: 'Doc2.md', isDirty: true, format: 'markdown', lastModified: Date.now() },
471
+ content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second document' }] }] },
472
+ annotations: [],
473
+ },
474
+ ],
475
+ activeDocumentId: 'doc-1',
476
+ currentUser,
477
+ mode: 'review',
478
+ onTabSelect: fn(),
479
+ onTabClose: fn(),
480
+ onAnnotationAdd: fn(),
481
+ onAnnotationUpdate: fn(),
482
+ },
483
+ play: async ({ canvasElement, args }) => {
484
+ const canvas = within(canvasElement)
485
+
486
+ // Wait for tab bar to render
487
+ await waitFor(() => {
488
+ expect(canvas.getByRole('tab', { name: /Doc1.md/i })).toBeInTheDocument()
489
+ })
490
+
491
+ // Verify first document is active
492
+ expect(canvas.getByText(/Document Review Example/i)).toBeInTheDocument()
493
+
494
+ // Click second tab
495
+ const secondTab = canvas.getByRole('tab', { name: /Doc2.md/i })
496
+ await userEvent.click(secondTab)
497
+
498
+ // Verify onTabSelect was called with correct document ID
499
+ await waitFor(() => {
500
+ expect(args.onTabSelect).toHaveBeenCalledWith('doc-2')
501
+ })
502
+ },
503
+ }
504
+
505
+ /**
506
+ * Test: Tab close button removes tab
507
+ * Verifies that clicking the close button on a tab calls onTabClose
508
+ */
509
+ export const TabCloseButtonRemovesTab: Story = {
510
+ args: {
511
+ documents: [
512
+ {
513
+ file: { id: 'doc-1', name: 'Doc1.md', isDirty: false, format: 'markdown', lastModified: Date.now() },
514
+ content: sampleContent,
515
+ annotations: sampleAnnotations,
516
+ },
517
+ {
518
+ file: { id: 'doc-2', name: 'Doc2.md', isDirty: false, format: 'markdown', lastModified: Date.now() },
519
+ content: sampleContent,
520
+ annotations: [],
521
+ },
522
+ ],
523
+ activeDocumentId: 'doc-1',
524
+ currentUser,
525
+ mode: 'review',
526
+ onTabSelect: fn(),
527
+ onTabClose: fn(),
528
+ onAnnotationAdd: fn(),
529
+ onAnnotationUpdate: fn(),
530
+ },
531
+ play: async ({ canvasElement, args }) => {
532
+ const canvas = within(canvasElement)
533
+
534
+ // Wait for tab bar to render
535
+ await waitFor(() => {
536
+ expect(canvas.getByRole('tab', { name: /Doc1.md/i })).toBeInTheDocument()
537
+ })
538
+
539
+ // Find the close button for the first tab
540
+ const closeButtons = canvas.getAllByRole('button', { name: /Close Doc1.md/i })
541
+ expect(closeButtons.length).toBeGreaterThan(0)
542
+
543
+ // Click the close button
544
+ await userEvent.click(closeButtons[0])
545
+
546
+ // Verify onTabClose was called with correct document ID
547
+ await waitFor(() => {
548
+ expect(args.onTabClose).toHaveBeenCalledWith('doc-1')
549
+ })
550
+ },
551
+ }
552
+
553
+ /**
554
+ * Test: Dirty indicator displays when isDirty is true
555
+ * Verifies that dirty indicator (dot) shows on tab with unsaved changes
556
+ */
557
+ export const DirtyIndicatorDisplaysOnUnsavedTab: Story = {
558
+ args: {
559
+ documents: [
560
+ {
561
+ file: { id: 'doc-1', name: 'Clean.md', isDirty: false, format: 'markdown', lastModified: Date.now() },
562
+ content: sampleContent,
563
+ annotations: [],
564
+ },
565
+ {
566
+ file: { id: 'doc-2', name: 'Dirty.md', isDirty: true, format: 'markdown', lastModified: Date.now() },
567
+ content: sampleContent,
568
+ annotations: [],
569
+ },
570
+ ],
571
+ activeDocumentId: 'doc-1',
572
+ currentUser,
573
+ mode: 'review',
574
+ onTabSelect: fn(),
575
+ onTabClose: fn(),
576
+ },
577
+ play: async ({ canvasElement, args }) => {
578
+ const canvas = within(canvasElement)
579
+
580
+ // Wait for tabs to render
581
+ await waitFor(() => {
582
+ expect(canvas.getByRole('tab', { name: /Clean.md/i })).toBeInTheDocument()
583
+ expect(canvas.getByRole('tab', { name: /Dirty.md/i })).toBeInTheDocument()
584
+ })
585
+
586
+ // Find dirty indicator on second tab
587
+ const dirtyTab = canvas.getByRole('tab', { name: /Dirty.md/i })
588
+
589
+ // Verify dirty indicator element exists (look for aria-label or specific element)
590
+ // The indicator should be a small dot or icon showing unsaved state
591
+ const dirtyIndicator = within(dirtyTab).getByLabelText(/unsaved changes/i)
592
+ expect(dirtyIndicator).toBeVisible()
593
+ },
594
+ }
595
+
596
+ /**
597
+ * Test: Multi-tab annotation interactions
598
+ * Verifies that annotations work correctly when switching between tabs
599
+ */
600
+ export const MultiTabAnnotationInteractions: Story = {
601
+ args: {
602
+ documents: [
603
+ {
604
+ file: { id: 'doc-1', name: 'Doc1.md', isDirty: false, format: 'markdown', lastModified: Date.now() },
605
+ content: sampleContent,
606
+ annotations: sampleAnnotations,
607
+ },
608
+ {
609
+ file: { id: 'doc-2', name: 'Doc2.md', isDirty: false, format: 'markdown', lastModified: Date.now() },
610
+ content: sampleContent,
611
+ annotations: [],
612
+ },
613
+ ],
614
+ activeDocumentId: 'doc-1',
615
+ currentUser,
616
+ mode: 'review',
617
+ onTabSelect: fn(),
618
+ onTabClose: fn(),
619
+ onAnnotationAdd: fn(),
620
+ onAnnotationUpdate: fn(),
621
+ },
622
+ play: async ({ canvasElement, args }) => {
623
+ const canvas = within(canvasElement)
624
+
625
+ // Wait for document to render
626
+ await waitFor(() => {
627
+ expect(canvas.getByText(/Document Review Example/i)).toBeInTheDocument()
628
+ })
629
+
630
+ // Click annotation on first tab
631
+ const annotatedText = canvas.getByText(/t with various annot/i)
632
+ await userEvent.click(annotatedText)
633
+
634
+ // Verify CommentBox appears
635
+ const replyInput = await screen.findByPlaceholderText("Reply...")
636
+ expect(replyInput).toBeVisible()
637
+
638
+ // Switch to second tab
639
+ const secondTab = canvas.getByRole('tab', { name: /Doc2.md/i })
640
+ await userEvent.click(secondTab)
641
+
642
+ // Verify tab switched and CommentBox closed
643
+ await waitFor(() => {
644
+ expect(args.onTabSelect).toHaveBeenCalledWith('doc-2')
645
+ })
646
+
647
+ // Return to first tab
648
+ const firstTab = canvas.getByRole('tab', { name: /Doc1.md/i })
649
+ await userEvent.click(firstTab)
650
+
651
+ // Verify document still renders correctly
652
+ await waitFor(() => {
653
+ expect(canvas.getByText(/Document Review Example/i)).toBeInTheDocument()
654
+ })
655
+ },
656
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { JSONContent } from '@tiptap/core'
11
11
  import type { Annotation, User } from '@/types/ai-editor'
12
+ import type { DocumentWithAnnotations, DocumentFile } from '@/types/ai-editor'
12
13
 
13
14
  /**
14
15
  * Sample document content for stories and tests
@@ -94,3 +95,51 @@ export const sampleAnnotations: Annotation[] = [
94
95
  },
95
96
  },
96
97
  ]
98
+
99
+ /**
100
+ * Sample document files for multi-tab stories
101
+ */
102
+ export const sampleDocumentFiles: DocumentFile[] = [
103
+ {
104
+ id: 'doc-1',
105
+ name: 'Introduction.md',
106
+ isDirty: false,
107
+ format: 'markdown',
108
+ lastModified: Date.now() - 3600000,
109
+ },
110
+ {
111
+ id: 'doc-2',
112
+ name: 'Workflow.json',
113
+ isDirty: true,
114
+ format: 'json',
115
+ lastModified: Date.now() - 1800000,
116
+ },
117
+ {
118
+ id: 'doc-3',
119
+ name: 'Configuration.md',
120
+ isDirty: false,
121
+ format: 'markdown',
122
+ lastModified: Date.now() - 900000,
123
+ },
124
+ ]
125
+
126
+ /**
127
+ * Sample multi-tab documents for stories
128
+ */
129
+ export const sampleMultiTabDocuments: DocumentWithAnnotations[] = [
130
+ {
131
+ file: sampleDocumentFiles[0],
132
+ content: sampleContent,
133
+ annotations: sampleAnnotations,
134
+ },
135
+ {
136
+ file: sampleDocumentFiles[1],
137
+ content: sampleContent,
138
+ annotations: [sampleAnnotations[0]],
139
+ },
140
+ {
141
+ file: sampleDocumentFiles[2],
142
+ content: sampleContent,
143
+ annotations: [],
144
+ },
145
+ ]