ux-toolkit 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -7
- package/agents/card-reviewer.md +173 -0
- package/agents/comparison-reviewer.md +143 -0
- package/agents/density-reviewer.md +207 -0
- package/agents/detail-page-reviewer.md +143 -0
- package/agents/editor-reviewer.md +165 -0
- package/agents/form-reviewer.md +156 -0
- package/agents/game-ui-reviewer.md +181 -0
- package/agents/list-page-reviewer.md +132 -0
- package/agents/navigation-reviewer.md +145 -0
- package/agents/panel-reviewer.md +182 -0
- package/agents/replay-reviewer.md +174 -0
- package/agents/settings-reviewer.md +166 -0
- package/agents/ux-auditor.md +145 -45
- package/agents/ux-engineer.md +211 -38
- package/dist/cli.js +172 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +172 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -4
- package/dist/index.d.ts +128 -4
- package/dist/index.js +172 -5
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/skills/canvas-grid-patterns/SKILL.md +367 -0
- package/skills/comparison-patterns/SKILL.md +354 -0
- package/skills/data-density-patterns/SKILL.md +493 -0
- package/skills/detail-page-patterns/SKILL.md +522 -0
- package/skills/drag-drop-patterns/SKILL.md +406 -0
- package/skills/editor-workspace-patterns/SKILL.md +552 -0
- package/skills/event-timeline-patterns/SKILL.md +542 -0
- package/skills/form-patterns/SKILL.md +608 -0
- package/skills/info-card-patterns/SKILL.md +531 -0
- package/skills/keyboard-shortcuts-patterns/SKILL.md +365 -0
- package/skills/list-page-patterns/SKILL.md +351 -0
- package/skills/modal-patterns/SKILL.md +750 -0
- package/skills/navigation-patterns/SKILL.md +476 -0
- package/skills/page-structure-patterns/SKILL.md +271 -0
- package/skills/playback-replay-patterns/SKILL.md +695 -0
- package/skills/react-ux-patterns/SKILL.md +434 -0
- package/skills/split-panel-patterns/SKILL.md +609 -0
- package/skills/status-visualization-patterns/SKILL.md +635 -0
- package/skills/toast-notification-patterns/SKILL.md +207 -0
- package/skills/turn-based-ui-patterns/SKILL.md +506 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: editor-workspace-patterns
|
|
3
|
+
description: Multi-tab editors, workspace management, dirty state, real-time validation, and complex editor UIs
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Editor & Workspace Patterns
|
|
8
|
+
|
|
9
|
+
Advanced patterns for building complex editor UIs with multi-tab workspaces, dirty state tracking, real-time validation, and professional workspace management. Designed for applications like code editors, customizers, and configuration tools.
|
|
10
|
+
|
|
11
|
+
## 1. Multi-Tab Interface
|
|
12
|
+
|
|
13
|
+
### Tab Structure
|
|
14
|
+
```tsx
|
|
15
|
+
interface Tab {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
icon?: React.ReactNode;
|
|
19
|
+
isDirty: boolean;
|
|
20
|
+
hasErrors: boolean;
|
|
21
|
+
errorCount: number;
|
|
22
|
+
isActive: boolean;
|
|
23
|
+
canClose: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Visual representation
|
|
27
|
+
// Active tab: bg-background border-b-2 border-primary
|
|
28
|
+
// Inactive tab: bg-muted hover:bg-muted/80
|
|
29
|
+
<div className="flex items-center gap-1 px-3 py-2">
|
|
30
|
+
{tab.isDirty && <span className="text-primary">●</span>}
|
|
31
|
+
<span>{tab.title}</span>
|
|
32
|
+
{tab.errorCount > 0 && (
|
|
33
|
+
<span className="ml-2 px-1.5 py-0.5 text-xs bg-destructive text-destructive-foreground rounded">
|
|
34
|
+
{tab.errorCount}
|
|
35
|
+
</span>
|
|
36
|
+
)}
|
|
37
|
+
<button className="ml-2 hover:bg-background/50 rounded p-0.5">×</button>
|
|
38
|
+
</div>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Tab Overflow Handling
|
|
42
|
+
```tsx
|
|
43
|
+
const TabList = () => {
|
|
44
|
+
const [showOverflow, setShowOverflow] = useState(false);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex items-center max-w-full">
|
|
48
|
+
<div className="flex-1 overflow-x-auto scrollbar-thin">
|
|
49
|
+
{visibleTabs.map(tab => <Tab key={tab.id} {...tab} />)}
|
|
50
|
+
</div>
|
|
51
|
+
{hasOverflow && (
|
|
52
|
+
<DropdownMenu>
|
|
53
|
+
<DropdownMenuTrigger className="px-2 py-1 hover:bg-muted">
|
|
54
|
+
⋯
|
|
55
|
+
</DropdownMenuTrigger>
|
|
56
|
+
<DropdownMenuContent>
|
|
57
|
+
{hiddenTabs.map(tab => (
|
|
58
|
+
<DropdownMenuItem key={tab.id}>
|
|
59
|
+
{tab.isDirty && "● "}{tab.title}
|
|
60
|
+
</DropdownMenuItem>
|
|
61
|
+
))}
|
|
62
|
+
</DropdownMenuContent>
|
|
63
|
+
</DropdownMenu>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Tab Reordering
|
|
71
|
+
Use `@dnd-kit/core` for drag-and-drop tab reordering with visual feedback during drag.
|
|
72
|
+
|
|
73
|
+
## 2. Dirty State Management
|
|
74
|
+
|
|
75
|
+
### DirtyStateTracker
|
|
76
|
+
```tsx
|
|
77
|
+
interface DirtyStateTracker {
|
|
78
|
+
dirtyTabs: Set<string>;
|
|
79
|
+
markDirty(tabId: string): void;
|
|
80
|
+
markClean(tabId: string): void;
|
|
81
|
+
isDirty(tabId: string): boolean;
|
|
82
|
+
hasAnyDirty(): boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class WorkspaceDirtyState implements DirtyStateTracker {
|
|
86
|
+
private dirtyTabs = new Set<string>();
|
|
87
|
+
private originalData = new Map<string, unknown>();
|
|
88
|
+
|
|
89
|
+
markDirty(tabId: string) {
|
|
90
|
+
this.dirtyTabs.add(tabId);
|
|
91
|
+
window.onbeforeunload = (e) => {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
return "Unsaved changes";
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
markClean(tabId: string) {
|
|
98
|
+
this.dirtyTabs.delete(tabId);
|
|
99
|
+
if (this.dirtyTabs.size === 0) {
|
|
100
|
+
window.onbeforeunload = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async promptSaveOnClose(tabId: string): Promise<'save' | 'discard' | 'cancel'> {
|
|
105
|
+
// Return user choice from dialog
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Close Tab Flow
|
|
111
|
+
```tsx
|
|
112
|
+
const handleCloseTab = async (tabId: string) => {
|
|
113
|
+
if (dirtyState.isDirty(tabId)) {
|
|
114
|
+
const choice = await confirmDialog({
|
|
115
|
+
title: "Unsaved Changes",
|
|
116
|
+
description: "Save changes before closing?",
|
|
117
|
+
actions: [
|
|
118
|
+
{ label: "Save", value: "save", variant: "default" },
|
|
119
|
+
{ label: "Discard", value: "discard", variant: "destructive" },
|
|
120
|
+
{ label: "Cancel", value: "cancel", variant: "ghost" }
|
|
121
|
+
]
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (choice === 'cancel') return;
|
|
125
|
+
if (choice === 'save') await saveTab(tabId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
removeTab(tabId);
|
|
129
|
+
};
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## 3. Real-Time Validation
|
|
133
|
+
|
|
134
|
+
### Validation Architecture
|
|
135
|
+
```tsx
|
|
136
|
+
interface ValidationResult {
|
|
137
|
+
field: string;
|
|
138
|
+
severity: 'error' | 'warning' | 'info';
|
|
139
|
+
message: string;
|
|
140
|
+
path?: string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface ValidationSummary {
|
|
144
|
+
errors: ValidationResult[];
|
|
145
|
+
warnings: ValidationResult[];
|
|
146
|
+
isValid: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const useRealtimeValidation = (data: unknown, schema: ZodSchema) => {
|
|
150
|
+
const [validation, setValidation] = useState<ValidationSummary>({
|
|
151
|
+
errors: [],
|
|
152
|
+
warnings: [],
|
|
153
|
+
isValid: true
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
const result = schema.safeParse(data);
|
|
159
|
+
if (!result.success) {
|
|
160
|
+
const errors = result.error.issues.map(issue => ({
|
|
161
|
+
field: issue.path.join('.'),
|
|
162
|
+
severity: 'error' as const,
|
|
163
|
+
message: issue.message,
|
|
164
|
+
path: issue.path as string[]
|
|
165
|
+
}));
|
|
166
|
+
setValidation({ errors, warnings: [], isValid: false });
|
|
167
|
+
} else {
|
|
168
|
+
setValidation({ errors: [], warnings: [], isValid: true });
|
|
169
|
+
}
|
|
170
|
+
}, 300); // Debounce
|
|
171
|
+
|
|
172
|
+
return () => clearTimeout(timer);
|
|
173
|
+
}, [data, schema]);
|
|
174
|
+
|
|
175
|
+
return validation;
|
|
176
|
+
};
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Inline Validation Display
|
|
180
|
+
```tsx
|
|
181
|
+
<div className="space-y-1">
|
|
182
|
+
<Input
|
|
183
|
+
className={cn(
|
|
184
|
+
validation.errors.length > 0 && "border-destructive",
|
|
185
|
+
validation.warnings.length > 0 && "border-yellow-500"
|
|
186
|
+
)}
|
|
187
|
+
/>
|
|
188
|
+
{validation.errors.map((err, i) => (
|
|
189
|
+
<p key={i} className="text-sm text-destructive">{err.message}</p>
|
|
190
|
+
))}
|
|
191
|
+
{validation.warnings.map((warn, i) => (
|
|
192
|
+
<p key={i} className="text-sm text-yellow-600">{warn.message}</p>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## 4. Workspace Layout
|
|
198
|
+
|
|
199
|
+
### Panel-Based Layout
|
|
200
|
+
```tsx
|
|
201
|
+
const WorkspaceLayout = () => {
|
|
202
|
+
return (
|
|
203
|
+
<ResizablePanelGroup direction="horizontal">
|
|
204
|
+
<ResizablePanel defaultSize={20} minSize={15} collapsible>
|
|
205
|
+
<Sidebar />
|
|
206
|
+
</ResizablePanel>
|
|
207
|
+
<ResizableHandle />
|
|
208
|
+
<ResizablePanel defaultSize={80}>
|
|
209
|
+
<div className="flex flex-col h-full">
|
|
210
|
+
<TabBar />
|
|
211
|
+
<div className="flex-1 overflow-auto">
|
|
212
|
+
<EditorContent />
|
|
213
|
+
</div>
|
|
214
|
+
<StatusBar />
|
|
215
|
+
</div>
|
|
216
|
+
</ResizablePanel>
|
|
217
|
+
</ResizablePanelGroup>
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Collapsible Panels
|
|
223
|
+
Store panel states in localStorage for persistence across sessions.
|
|
224
|
+
|
|
225
|
+
## 5. Undo/Redo
|
|
226
|
+
|
|
227
|
+
### Command Pattern
|
|
228
|
+
```tsx
|
|
229
|
+
interface Command {
|
|
230
|
+
execute(): void;
|
|
231
|
+
undo(): void;
|
|
232
|
+
redo(): void;
|
|
233
|
+
describe(): string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
class UndoStack {
|
|
237
|
+
private history: Command[] = [];
|
|
238
|
+
private currentIndex = -1;
|
|
239
|
+
|
|
240
|
+
execute(command: Command) {
|
|
241
|
+
command.execute();
|
|
242
|
+
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
243
|
+
this.history.push(command);
|
|
244
|
+
this.currentIndex++;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
undo() {
|
|
248
|
+
if (this.canUndo()) {
|
|
249
|
+
this.history[this.currentIndex].undo();
|
|
250
|
+
this.currentIndex--;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
redo() {
|
|
255
|
+
if (this.canRedo()) {
|
|
256
|
+
this.currentIndex++;
|
|
257
|
+
this.history[this.currentIndex].redo();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
canUndo() { return this.currentIndex >= 0; }
|
|
262
|
+
canRedo() { return this.currentIndex < this.history.length - 1; }
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Keyboard Shortcuts
|
|
267
|
+
```tsx
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
const handler = (e: KeyboardEvent) => {
|
|
270
|
+
if (e.ctrlKey || e.metaKey) {
|
|
271
|
+
if (e.key === 'z' && !e.shiftKey) {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
undoStack.undo();
|
|
274
|
+
} else if (e.key === 'z' && e.shiftKey || e.key === 'y') {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
undoStack.redo();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
window.addEventListener('keydown', handler);
|
|
282
|
+
return () => window.removeEventListener('keydown', handler);
|
|
283
|
+
}, []);
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## 6. Auto-Save Patterns
|
|
287
|
+
|
|
288
|
+
### Debounced Auto-Save
|
|
289
|
+
```tsx
|
|
290
|
+
const useAutoSave = (data: unknown, saveFunc: (data: unknown) => Promise<void>) => {
|
|
291
|
+
const [saving, setSaving] = useState(false);
|
|
292
|
+
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
const timer = setTimeout(async () => {
|
|
296
|
+
setSaving(true);
|
|
297
|
+
try {
|
|
298
|
+
await saveFunc(data);
|
|
299
|
+
setLastSaved(new Date());
|
|
300
|
+
} catch (error) {
|
|
301
|
+
// Handle conflict or error
|
|
302
|
+
toast.error("Save failed. Please refresh.");
|
|
303
|
+
} finally {
|
|
304
|
+
setSaving(false);
|
|
305
|
+
}
|
|
306
|
+
}, 2000); // 2s debounce
|
|
307
|
+
|
|
308
|
+
return () => clearTimeout(timer);
|
|
309
|
+
}, [data, saveFunc]);
|
|
310
|
+
|
|
311
|
+
return { saving, lastSaved };
|
|
312
|
+
};
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Save Indicators
|
|
316
|
+
```tsx
|
|
317
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
318
|
+
{saving && (
|
|
319
|
+
<>
|
|
320
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
321
|
+
<span>Saving...</span>
|
|
322
|
+
</>
|
|
323
|
+
)}
|
|
324
|
+
{!saving && lastSaved && (
|
|
325
|
+
<span>Saved {formatDistanceToNow(lastSaved)} ago</span>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Conflict Detection
|
|
331
|
+
```tsx
|
|
332
|
+
const detectConflict = async (docId: string, localVersion: number) => {
|
|
333
|
+
const serverDoc = await fetchDocument(docId);
|
|
334
|
+
if (serverDoc.version > localVersion) {
|
|
335
|
+
// Show conflict resolution dialog
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
339
|
+
};
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## 7. Configuration Tabs
|
|
343
|
+
|
|
344
|
+
### URL-Synced Tabs
|
|
345
|
+
```tsx
|
|
346
|
+
const useTabSync = () => {
|
|
347
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
348
|
+
const activeTab = searchParams.get('tab') || 'general';
|
|
349
|
+
|
|
350
|
+
const setActiveTab = (tab: string) => {
|
|
351
|
+
setSearchParams({ tab });
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
return [activeTab, setActiveTab] as const;
|
|
355
|
+
};
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Lazy Loading Tab Content
|
|
359
|
+
```tsx
|
|
360
|
+
const TabContent = ({ tabId }: { tabId: string }) => {
|
|
361
|
+
const [loaded, setLoaded] = useState(false);
|
|
362
|
+
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
if (!loaded) {
|
|
365
|
+
// Load heavy content only when tab becomes active
|
|
366
|
+
loadTabContent(tabId).then(() => setLoaded(true));
|
|
367
|
+
}
|
|
368
|
+
}, [tabId, loaded]);
|
|
369
|
+
|
|
370
|
+
if (!loaded) return <Skeleton />;
|
|
371
|
+
return <ActualContent />;
|
|
372
|
+
};
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Tab Groups
|
|
376
|
+
```tsx
|
|
377
|
+
<Tabs>
|
|
378
|
+
<TabsList>
|
|
379
|
+
<TabsGroup label="General">
|
|
380
|
+
<TabsTrigger value="profile">Profile</TabsTrigger>
|
|
381
|
+
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
382
|
+
</TabsGroup>
|
|
383
|
+
<Separator orientation="vertical" className="h-4" />
|
|
384
|
+
<TabsGroup label="Advanced">
|
|
385
|
+
<TabsTrigger value="api">API</TabsTrigger>
|
|
386
|
+
<TabsTrigger value="integrations">Integrations</TabsTrigger>
|
|
387
|
+
</TabsGroup>
|
|
388
|
+
</TabsList>
|
|
389
|
+
</Tabs>
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
## 8. Validation Display
|
|
393
|
+
|
|
394
|
+
### Error vs Warning Colors
|
|
395
|
+
- **Errors (blocking)**: Red/destructive - `border-destructive`, `text-destructive`
|
|
396
|
+
- **Warnings (non-blocking)**: Yellow - `border-yellow-500`, `text-yellow-600`
|
|
397
|
+
- **Info**: Blue - `border-blue-500`, `text-blue-600`
|
|
398
|
+
|
|
399
|
+
### Tab Badge with Error Count
|
|
400
|
+
```tsx
|
|
401
|
+
{tab.errorCount > 0 && (
|
|
402
|
+
<Badge variant="destructive" className="ml-2">
|
|
403
|
+
{tab.errorCount}
|
|
404
|
+
</Badge>
|
|
405
|
+
)}
|
|
406
|
+
{tab.warningCount > 0 && tab.errorCount === 0 && (
|
|
407
|
+
<Badge variant="outline" className="ml-2 border-yellow-500 text-yellow-600">
|
|
408
|
+
{tab.warningCount}
|
|
409
|
+
</Badge>
|
|
410
|
+
)}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Validation Summary Panel
|
|
414
|
+
```tsx
|
|
415
|
+
<Card>
|
|
416
|
+
<CardHeader>
|
|
417
|
+
<CardTitle>Validation Results</CardTitle>
|
|
418
|
+
</CardHeader>
|
|
419
|
+
<CardContent>
|
|
420
|
+
{validation.errors.length > 0 && (
|
|
421
|
+
<div className="space-y-2">
|
|
422
|
+
<h4 className="text-sm font-medium text-destructive">
|
|
423
|
+
{validation.errors.length} Errors
|
|
424
|
+
</h4>
|
|
425
|
+
{validation.errors.map((err, i) => (
|
|
426
|
+
<button
|
|
427
|
+
key={i}
|
|
428
|
+
onClick={() => jumpToField(err.field)}
|
|
429
|
+
className="block w-full text-left text-sm p-2 hover:bg-muted rounded"
|
|
430
|
+
>
|
|
431
|
+
{err.field}: {err.message}
|
|
432
|
+
</button>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
</CardContent>
|
|
437
|
+
</Card>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## 9. WorkspaceProvider Pattern
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
interface WorkspaceContextValue {
|
|
444
|
+
tabs: Tab[];
|
|
445
|
+
activeTabId: string | null;
|
|
446
|
+
dirtyState: DirtyStateTracker;
|
|
447
|
+
validation: Map<string, ValidationSummary>;
|
|
448
|
+
addTab: (tab: Omit<Tab, 'id'>) => string;
|
|
449
|
+
removeTab: (id: string) => Promise<void>;
|
|
450
|
+
setActiveTab: (id: string) => void;
|
|
451
|
+
saveTab: (id: string) => Promise<void>;
|
|
452
|
+
saveAll: () => Promise<void>;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const WorkspaceContext = createContext<WorkspaceContextValue | null>(null);
|
|
456
|
+
|
|
457
|
+
export const WorkspaceProvider = ({ children }: PropsWithChildren) => {
|
|
458
|
+
const [tabs, setTabs] = useState<Tab[]>([]);
|
|
459
|
+
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
|
460
|
+
const dirtyState = useMemo(() => new WorkspaceDirtyState(), []);
|
|
461
|
+
|
|
462
|
+
const removeTab = async (id: string) => {
|
|
463
|
+
const tab = tabs.find(t => t.id === id);
|
|
464
|
+
if (!tab) return;
|
|
465
|
+
|
|
466
|
+
if (dirtyState.isDirty(id)) {
|
|
467
|
+
const choice = await dirtyState.promptSaveOnClose(id);
|
|
468
|
+
if (choice === 'cancel') return;
|
|
469
|
+
if (choice === 'save') await saveTab(id);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
setTabs(prev => prev.filter(t => t.id !== id));
|
|
473
|
+
dirtyState.markClean(id);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const saveAll = async () => {
|
|
477
|
+
const dirtyTabIds = tabs.filter(t => dirtyState.isDirty(t.id)).map(t => t.id);
|
|
478
|
+
await Promise.all(dirtyTabIds.map(id => saveTab(id)));
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<WorkspaceContext.Provider value={{
|
|
483
|
+
tabs,
|
|
484
|
+
activeTabId,
|
|
485
|
+
dirtyState,
|
|
486
|
+
addTab,
|
|
487
|
+
removeTab,
|
|
488
|
+
setActiveTab,
|
|
489
|
+
saveTab,
|
|
490
|
+
saveAll
|
|
491
|
+
}}>
|
|
492
|
+
{children}
|
|
493
|
+
</WorkspaceContext.Provider>
|
|
494
|
+
);
|
|
495
|
+
};
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
## 10. Audit Checklist
|
|
499
|
+
|
|
500
|
+
### Multi-Tab Interface
|
|
501
|
+
- [CRITICAL] Tab close button requires confirmation if dirty
|
|
502
|
+
- [CRITICAL] Dirty indicator (●) visible on unsaved tabs
|
|
503
|
+
- [MAJOR] Error count badge visible on tabs with validation errors
|
|
504
|
+
- [MAJOR] Tab overflow handled with dropdown or scroll
|
|
505
|
+
- [MINOR] Tab reordering supported via drag-and-drop
|
|
506
|
+
- [MINOR] Tab icons displayed for visual identification
|
|
507
|
+
|
|
508
|
+
### Dirty State Management
|
|
509
|
+
- [CRITICAL] Browser unload warning when dirty tabs exist
|
|
510
|
+
- [CRITICAL] "Save/Discard/Cancel" dialog on close dirty tab
|
|
511
|
+
- [MAJOR] Dirty state persists across component remounts
|
|
512
|
+
- [MAJOR] "Save All" action available when multiple tabs dirty
|
|
513
|
+
- [MINOR] Dirty state cleared after successful save
|
|
514
|
+
|
|
515
|
+
### Real-Time Validation
|
|
516
|
+
- [CRITICAL] Validation runs on user input (debounced)
|
|
517
|
+
- [CRITICAL] Errors prevent save/submit actions
|
|
518
|
+
- [MAJOR] Warnings shown but don't block actions
|
|
519
|
+
- [MAJOR] Validation messages appear inline near fields
|
|
520
|
+
- [MINOR] Validation summary panel shows all issues
|
|
521
|
+
- [MINOR] Click validation message jumps to field
|
|
522
|
+
|
|
523
|
+
### Workspace Layout
|
|
524
|
+
- [MAJOR] Panels resizable with visible handles
|
|
525
|
+
- [MAJOR] Sidebar collapsible with toggle button
|
|
526
|
+
- [MINOR] Panel sizes persist in localStorage
|
|
527
|
+
- [MINOR] Responsive layout on smaller screens
|
|
528
|
+
|
|
529
|
+
### Undo/Redo
|
|
530
|
+
- [CRITICAL] Ctrl+Z / Cmd+Z triggers undo
|
|
531
|
+
- [CRITICAL] Ctrl+Shift+Z / Cmd+Shift+Z triggers redo
|
|
532
|
+
- [MAJOR] Undo/redo buttons disabled when stack empty
|
|
533
|
+
- [MINOR] Undo history tooltip shows command description
|
|
534
|
+
|
|
535
|
+
### Auto-Save
|
|
536
|
+
- [MAJOR] Auto-save debounced (1-3 seconds)
|
|
537
|
+
- [MAJOR] Save indicator shows "Saving..." and "Saved" states
|
|
538
|
+
- [CRITICAL] Conflict detection before auto-save
|
|
539
|
+
- [MINOR] Manual save option always available
|
|
540
|
+
- [MINOR] Last saved timestamp displayed
|
|
541
|
+
|
|
542
|
+
### Performance
|
|
543
|
+
- [CRITICAL] Tab content lazy-loaded (not all at once)
|
|
544
|
+
- [MAJOR] Validation debounced to avoid excessive re-renders
|
|
545
|
+
- [MAJOR] Large lists virtualized in editor panels
|
|
546
|
+
- [MINOR] Tab switches feel instant (<100ms)
|
|
547
|
+
|
|
548
|
+
### Accessibility
|
|
549
|
+
- [CRITICAL] Keyboard navigation works (Tab, Arrow keys)
|
|
550
|
+
- [MAJOR] Focus visible on all interactive elements
|
|
551
|
+
- [MAJOR] Screen reader announces tab changes and validation
|
|
552
|
+
- [MINOR] Tooltips on icon-only buttons
|