ai-design-system 0.1.13 → 0.1.15
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/components/composites/AdjustableLayout/AdjustableLayout.tsx +23 -20
- package/components/composites/DocumentTabBar/DocumentTabBar.stories.tsx +122 -0
- package/components/composites/DocumentTabBar/DocumentTabBar.tsx +137 -0
- package/components/composites/DocumentTabBar/index.ts +2 -0
- package/components/composites/index.ts +4 -0
- package/components/features/AIDocEditor/AIDocEditor.behaviors.stories.tsx +208 -3
- package/components/features/AIDocEditor/AIDocEditor.mocks.ts +49 -0
- package/components/features/AIDocEditor/AIDocEditor.stories.tsx +45 -94
- package/components/features/AIDocEditor/AIDocEditor.tsx +156 -28
- package/components/features/AIDocEditor/README.md +176 -24
- package/components/features/AIDocEditor/index.ts +17 -1
- package/components/features/AIDocEditor/useAIDocEditor.d.ts +85 -2
- package/components/features/AIDocEditor/useAIDocEditor.mock.ts +179 -30
- package/dist/index.cjs +176 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +69 -0
- package/dist/index.d.ts +90 -22
- package/dist/index.js +177 -45
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -53,28 +53,31 @@ export const AdjustableLayout = React.memo<AdjustableLayoutProps>(
|
|
|
53
53
|
muted: "group-hover:bg-muted/30"
|
|
54
54
|
}
|
|
55
55
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
|
|
57
|
+
// Compute default sizes (server-safe — no localStorage access)
|
|
58
|
+
const defaultSizes = React.useMemo(() => {
|
|
59
|
+
const raw = sections.map(section => section.defaultSize ?? (100 / sections.length))
|
|
60
|
+
const total = raw.reduce((sum, size) => sum + size, 0)
|
|
61
|
+
return raw.map(size => (size / total) * 100)
|
|
62
|
+
}, [sections])
|
|
63
|
+
|
|
64
|
+
const [sizes, setSizes] = React.useState<number[]>(defaultSizes)
|
|
65
|
+
|
|
66
|
+
// After hydration, overwrite with persisted sizes if available
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
if (!storageKey) return
|
|
69
|
+
const saved = localStorage.getItem(storageKey)
|
|
70
|
+
if (!saved) return
|
|
71
|
+
try {
|
|
72
|
+
const parsed: number[] = JSON.parse(saved)
|
|
73
|
+
if (Array.isArray(parsed) && parsed.length === sections.length) {
|
|
74
|
+
setSizes(parsed)
|
|
66
75
|
}
|
|
76
|
+
} catch {
|
|
77
|
+
// ignore malformed storage
|
|
67
78
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const defaultSizes = sections.map(section =>
|
|
71
|
-
section.defaultSize ?? (100 / sections.length)
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
// Normalize to 100%
|
|
75
|
-
const total = defaultSizes.reduce((sum, size) => sum + size, 0)
|
|
76
|
-
return defaultSizes.map(size => (size / total) * 100)
|
|
77
|
-
})
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [storageKey])
|
|
78
81
|
|
|
79
82
|
const [draggingIndex, setDraggingIndex] = React.useState<number | null>(null)
|
|
80
83
|
const [startX, setStartX] = React.useState(0)
|
|
@@ -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'
|
|
@@ -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
|
|
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
|
|
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
|
+
]
|