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.
Files changed (44) hide show
  1. package/README.md +113 -7
  2. package/agents/card-reviewer.md +173 -0
  3. package/agents/comparison-reviewer.md +143 -0
  4. package/agents/density-reviewer.md +207 -0
  5. package/agents/detail-page-reviewer.md +143 -0
  6. package/agents/editor-reviewer.md +165 -0
  7. package/agents/form-reviewer.md +156 -0
  8. package/agents/game-ui-reviewer.md +181 -0
  9. package/agents/list-page-reviewer.md +132 -0
  10. package/agents/navigation-reviewer.md +145 -0
  11. package/agents/panel-reviewer.md +182 -0
  12. package/agents/replay-reviewer.md +174 -0
  13. package/agents/settings-reviewer.md +166 -0
  14. package/agents/ux-auditor.md +145 -45
  15. package/agents/ux-engineer.md +211 -38
  16. package/dist/cli.js +172 -5
  17. package/dist/cli.js.map +1 -1
  18. package/dist/index.cjs +172 -5
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +128 -4
  21. package/dist/index.d.ts +128 -4
  22. package/dist/index.js +172 -5
  23. package/dist/index.js.map +1 -1
  24. package/package.json +6 -4
  25. package/skills/canvas-grid-patterns/SKILL.md +367 -0
  26. package/skills/comparison-patterns/SKILL.md +354 -0
  27. package/skills/data-density-patterns/SKILL.md +493 -0
  28. package/skills/detail-page-patterns/SKILL.md +522 -0
  29. package/skills/drag-drop-patterns/SKILL.md +406 -0
  30. package/skills/editor-workspace-patterns/SKILL.md +552 -0
  31. package/skills/event-timeline-patterns/SKILL.md +542 -0
  32. package/skills/form-patterns/SKILL.md +608 -0
  33. package/skills/info-card-patterns/SKILL.md +531 -0
  34. package/skills/keyboard-shortcuts-patterns/SKILL.md +365 -0
  35. package/skills/list-page-patterns/SKILL.md +351 -0
  36. package/skills/modal-patterns/SKILL.md +750 -0
  37. package/skills/navigation-patterns/SKILL.md +476 -0
  38. package/skills/page-structure-patterns/SKILL.md +271 -0
  39. package/skills/playback-replay-patterns/SKILL.md +695 -0
  40. package/skills/react-ux-patterns/SKILL.md +434 -0
  41. package/skills/split-panel-patterns/SKILL.md +609 -0
  42. package/skills/status-visualization-patterns/SKILL.md +635 -0
  43. package/skills/toast-notification-patterns/SKILL.md +207 -0
  44. package/skills/turn-based-ui-patterns/SKILL.md +506 -0
@@ -0,0 +1,750 @@
1
+ ---
2
+ name: modal-patterns
3
+ description: Modal dialog patterns including confirmation, edit, selector, and wizard modals with proper focus management and accessibility
4
+ license: MIT
5
+ ---
6
+
7
+ # Modal Dialog UX Patterns
8
+
9
+ Modals interrupt user flow and must be used intentionally. This skill covers proper modal implementation.
10
+
11
+ ## When to Use Modals
12
+
13
+ ### Use Modals For
14
+ | Use Case | Example |
15
+ |----------|---------|
16
+ | Destructive confirmations | Delete item, leave without saving |
17
+ | Quick edits | Rename, update single field |
18
+ | Selection from list | Pick item, choose option |
19
+ | Critical alerts | Session expiring, unsaved changes |
20
+ | Focused sub-tasks | Add new item inline |
21
+
22
+ ### DON'T Use Modals For
23
+ | Avoid | Alternative |
24
+ |-------|-------------|
25
+ | Long forms (5+ fields) | Dedicated page |
26
+ | Complex workflows | Multi-step page |
27
+ | Informational content | Inline expansion |
28
+ | Frequent actions | Inline editing |
29
+
30
+ ## Base Modal Component
31
+
32
+ ### Modal Shell Structure
33
+ ```tsx
34
+ interface ModalProps {
35
+ isOpen: boolean;
36
+ onClose: () => void;
37
+ title: string;
38
+ description?: string;
39
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
40
+ children: ReactNode;
41
+ footer?: ReactNode;
42
+ closeOnOverlayClick?: boolean;
43
+ closeOnEscape?: boolean;
44
+ }
45
+
46
+ function Modal({
47
+ isOpen,
48
+ onClose,
49
+ title,
50
+ description,
51
+ size = 'md',
52
+ children,
53
+ footer,
54
+ closeOnOverlayClick = true,
55
+ closeOnEscape = true,
56
+ }: ModalProps) {
57
+ const modalRef = useRef<HTMLDivElement>(null);
58
+
59
+ // Lock body scroll when open
60
+ useEffect(() => {
61
+ if (isOpen) {
62
+ document.body.style.overflow = 'hidden';
63
+ return () => { document.body.style.overflow = ''; };
64
+ }
65
+ }, [isOpen]);
66
+
67
+ // Handle escape key
68
+ useEffect(() => {
69
+ if (!isOpen || !closeOnEscape) return;
70
+
71
+ const handleEscape = (e: KeyboardEvent) => {
72
+ if (e.key === 'Escape') onClose();
73
+ };
74
+
75
+ document.addEventListener('keydown', handleEscape);
76
+ return () => document.removeEventListener('keydown', handleEscape);
77
+ }, [isOpen, closeOnEscape, onClose]);
78
+
79
+ // Focus trap
80
+ useEffect(() => {
81
+ if (!isOpen) return;
82
+
83
+ const focusableElements = modalRef.current?.querySelectorAll(
84
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
85
+ );
86
+ const firstElement = focusableElements?.[0] as HTMLElement;
87
+ const lastElement = focusableElements?.[focusableElements.length - 1] as HTMLElement;
88
+
89
+ firstElement?.focus();
90
+
91
+ const handleTab = (e: KeyboardEvent) => {
92
+ if (e.key !== 'Tab') return;
93
+
94
+ if (e.shiftKey && document.activeElement === firstElement) {
95
+ e.preventDefault();
96
+ lastElement?.focus();
97
+ } else if (!e.shiftKey && document.activeElement === lastElement) {
98
+ e.preventDefault();
99
+ firstElement?.focus();
100
+ }
101
+ };
102
+
103
+ document.addEventListener('keydown', handleTab);
104
+ return () => document.removeEventListener('keydown', handleTab);
105
+ }, [isOpen]);
106
+
107
+ if (!isOpen) return null;
108
+
109
+ const sizeClasses = {
110
+ sm: 'max-w-sm',
111
+ md: 'max-w-md',
112
+ lg: 'max-w-lg',
113
+ xl: 'max-w-xl',
114
+ full: 'max-w-4xl',
115
+ };
116
+
117
+ return (
118
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
119
+ {/* Backdrop */}
120
+ <div
121
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm"
122
+ onClick={closeOnOverlayClick ? onClose : undefined}
123
+ aria-hidden="true"
124
+ />
125
+
126
+ {/* Modal */}
127
+ <div
128
+ ref={modalRef}
129
+ role="dialog"
130
+ aria-modal="true"
131
+ aria-labelledby="modal-title"
132
+ aria-describedby={description ? 'modal-description' : undefined}
133
+ className={`
134
+ relative w-full ${sizeClasses[size]}
135
+ bg-surface-base border border-border rounded-xl shadow-2xl
136
+ animate-in fade-in zoom-in-95 duration-200
137
+ `}
138
+ >
139
+ {/* Header */}
140
+ <div className="flex items-start justify-between p-5 border-b border-border">
141
+ <div>
142
+ <h2 id="modal-title" className="text-lg font-semibold text-white">
143
+ {title}
144
+ </h2>
145
+ {description && (
146
+ <p id="modal-description" className="mt-1 text-sm text-text-secondary">
147
+ {description}
148
+ </p>
149
+ )}
150
+ </div>
151
+ <button
152
+ onClick={onClose}
153
+ className="p-1.5 -mr-1.5 -mt-1.5 rounded-lg text-text-muted hover:text-white hover:bg-surface-raised"
154
+ aria-label="Close modal"
155
+ >
156
+ <XIcon className="w-5 h-5" />
157
+ </button>
158
+ </div>
159
+
160
+ {/* Content */}
161
+ <div className="p-5 max-h-[60vh] overflow-y-auto">
162
+ {children}
163
+ </div>
164
+
165
+ {/* Footer */}
166
+ {footer && (
167
+ <div className="flex items-center justify-end gap-3 p-5 border-t border-border bg-surface-deep/50 rounded-b-xl">
168
+ {footer}
169
+ </div>
170
+ )}
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+ ```
176
+
177
+ ## Confirmation Modal
178
+
179
+ ### Delete Confirmation Pattern
180
+ ```tsx
181
+ interface DeleteConfirmModalProps {
182
+ isOpen: boolean;
183
+ onClose: () => void;
184
+ onConfirm: () => Promise<void>;
185
+ itemName: string;
186
+ itemType?: string;
187
+ warningMessage?: string;
188
+ }
189
+
190
+ function DeleteConfirmModal({
191
+ isOpen,
192
+ onClose,
193
+ onConfirm,
194
+ itemName,
195
+ itemType = 'item',
196
+ warningMessage,
197
+ }: DeleteConfirmModalProps) {
198
+ const [isDeleting, setIsDeleting] = useState(false);
199
+ const [confirmText, setConfirmText] = useState('');
200
+
201
+ // Reset state when modal closes
202
+ useEffect(() => {
203
+ if (!isOpen) {
204
+ setConfirmText('');
205
+ setIsDeleting(false);
206
+ }
207
+ }, [isOpen]);
208
+
209
+ const handleConfirm = async () => {
210
+ setIsDeleting(true);
211
+ try {
212
+ await onConfirm();
213
+ onClose();
214
+ } catch (error) {
215
+ // Error handling - keep modal open
216
+ setIsDeleting(false);
217
+ }
218
+ };
219
+
220
+ const requiresTypedConfirmation = warningMessage !== undefined;
221
+ const canConfirm = !requiresTypedConfirmation || confirmText === itemName;
222
+
223
+ return (
224
+ <Modal
225
+ isOpen={isOpen}
226
+ onClose={onClose}
227
+ title={`Delete ${itemType}`}
228
+ size="sm"
229
+ closeOnOverlayClick={!isDeleting}
230
+ closeOnEscape={!isDeleting}
231
+ footer={
232
+ <>
233
+ <Button
234
+ variant="ghost"
235
+ onClick={onClose}
236
+ disabled={isDeleting}
237
+ >
238
+ Cancel
239
+ </Button>
240
+ <Button
241
+ variant="danger"
242
+ onClick={handleConfirm}
243
+ disabled={isDeleting || !canConfirm}
244
+ >
245
+ {isDeleting ? (
246
+ <>
247
+ <Spinner className="w-4 h-4 mr-2" />
248
+ Deleting...
249
+ </>
250
+ ) : (
251
+ 'Delete'
252
+ )}
253
+ </Button>
254
+ </>
255
+ }
256
+ >
257
+ <div className="text-center">
258
+ {/* Warning icon */}
259
+ <div className="mx-auto w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center mb-4">
260
+ <TrashIcon className="w-6 h-6 text-red-400" />
261
+ </div>
262
+
263
+ <p className="text-text-primary mb-2">
264
+ Are you sure you want to delete{' '}
265
+ <span className="font-semibold text-white">"{itemName}"</span>?
266
+ </p>
267
+
268
+ <p className="text-sm text-text-secondary mb-4">
269
+ This action cannot be undone.
270
+ </p>
271
+
272
+ {/* Warning message with typed confirmation */}
273
+ {warningMessage && (
274
+ <div className="mt-4 p-3 bg-red-900/20 border border-red-600/30 rounded-lg text-left">
275
+ <p className="text-sm text-red-400 mb-3">{warningMessage}</p>
276
+ <label className="block">
277
+ <span className="text-xs text-text-muted">
278
+ Type "{itemName}" to confirm:
279
+ </span>
280
+ <input
281
+ type="text"
282
+ value={confirmText}
283
+ onChange={(e) => setConfirmText(e.target.value)}
284
+ className="mt-1 w-full px-3 py-2 bg-surface-deep border border-border rounded-lg text-white text-sm"
285
+ placeholder={itemName}
286
+ disabled={isDeleting}
287
+ />
288
+ </label>
289
+ </div>
290
+ )}
291
+ </div>
292
+ </Modal>
293
+ );
294
+ }
295
+ ```
296
+
297
+ ## Edit Modal
298
+
299
+ ### Form Edit Modal Pattern
300
+ ```tsx
301
+ interface EditModalProps<T> {
302
+ isOpen: boolean;
303
+ onClose: () => void;
304
+ onSave: (data: T) => Promise<void>;
305
+ initialData: T;
306
+ title: string;
307
+ children: (props: {
308
+ data: T;
309
+ setData: React.Dispatch<React.SetStateAction<T>>;
310
+ errors: Record<string, string>;
311
+ }) => ReactNode;
312
+ validate?: (data: T) => Record<string, string>;
313
+ }
314
+
315
+ function EditModal<T extends Record<string, any>>({
316
+ isOpen,
317
+ onClose,
318
+ onSave,
319
+ initialData,
320
+ title,
321
+ children,
322
+ validate,
323
+ }: EditModalProps<T>) {
324
+ const [data, setData] = useState<T>(initialData);
325
+ const [errors, setErrors] = useState<Record<string, string>>({});
326
+ const [isSaving, setIsSaving] = useState(false);
327
+ const [isDirty, setIsDirty] = useState(false);
328
+
329
+ // Reset form when modal opens with new data
330
+ useEffect(() => {
331
+ if (isOpen) {
332
+ setData(initialData);
333
+ setErrors({});
334
+ setIsDirty(false);
335
+ }
336
+ }, [isOpen, initialData]);
337
+
338
+ // Track dirty state
339
+ useEffect(() => {
340
+ if (isOpen) {
341
+ const hasChanges = JSON.stringify(data) !== JSON.stringify(initialData);
342
+ setIsDirty(hasChanges);
343
+ }
344
+ }, [data, initialData, isOpen]);
345
+
346
+ const handleClose = () => {
347
+ if (isDirty && !confirm('You have unsaved changes. Discard?')) {
348
+ return;
349
+ }
350
+ onClose();
351
+ };
352
+
353
+ const handleSave = async () => {
354
+ // Validate
355
+ if (validate) {
356
+ const validationErrors = validate(data);
357
+ if (Object.keys(validationErrors).length > 0) {
358
+ setErrors(validationErrors);
359
+ return;
360
+ }
361
+ }
362
+
363
+ setIsSaving(true);
364
+ try {
365
+ await onSave(data);
366
+ onClose();
367
+ } catch (error) {
368
+ setErrors({ _form: 'Failed to save. Please try again.' });
369
+ } finally {
370
+ setIsSaving(false);
371
+ }
372
+ };
373
+
374
+ return (
375
+ <Modal
376
+ isOpen={isOpen}
377
+ onClose={handleClose}
378
+ title={title}
379
+ size="md"
380
+ closeOnOverlayClick={!isDirty}
381
+ footer={
382
+ <>
383
+ <Button variant="ghost" onClick={handleClose} disabled={isSaving}>
384
+ Cancel
385
+ </Button>
386
+ <Button
387
+ variant="primary"
388
+ onClick={handleSave}
389
+ disabled={isSaving || !isDirty}
390
+ >
391
+ {isSaving ? 'Saving...' : 'Save Changes'}
392
+ </Button>
393
+ </>
394
+ }
395
+ >
396
+ {errors._form && (
397
+ <div className="mb-4 p-3 bg-red-900/20 border border-red-600/30 rounded-lg text-sm text-red-400">
398
+ {errors._form}
399
+ </div>
400
+ )}
401
+ {children({ data, setData, errors })}
402
+ </Modal>
403
+ );
404
+ }
405
+
406
+ // Usage example
407
+ <EditModal
408
+ isOpen={isEditOpen}
409
+ onClose={() => setIsEditOpen(false)}
410
+ onSave={handleSaveIdentity}
411
+ initialData={{ name: pilot.name, callsign: pilot.callsign }}
412
+ title="Edit Identity"
413
+ validate={(data) => {
414
+ const errors: Record<string, string> = {};
415
+ if (!data.name.trim()) errors.name = 'Name is required';
416
+ if (!data.callsign.trim()) errors.callsign = 'Callsign is required';
417
+ return errors;
418
+ }}
419
+ >
420
+ {({ data, setData, errors }) => (
421
+ <div className="space-y-4">
422
+ <FormField label="Name" error={errors.name}>
423
+ <Input
424
+ value={data.name}
425
+ onChange={(e) => setData(d => ({ ...d, name: e.target.value }))}
426
+ />
427
+ </FormField>
428
+ <FormField label="Callsign" error={errors.callsign}>
429
+ <Input
430
+ value={data.callsign}
431
+ onChange={(e) => setData(d => ({ ...d, callsign: e.target.value }))}
432
+ />
433
+ </FormField>
434
+ </div>
435
+ )}
436
+ </EditModal>
437
+ ```
438
+
439
+ ## Selector Modal
440
+
441
+ ### Item Selection Modal
442
+ ```tsx
443
+ interface SelectorModalProps<T> {
444
+ isOpen: boolean;
445
+ onClose: () => void;
446
+ onSelect: (item: T) => void;
447
+ items: T[];
448
+ selectedId?: string;
449
+ title: string;
450
+ searchPlaceholder?: string;
451
+ renderItem: (item: T, isSelected: boolean) => ReactNode;
452
+ getItemId: (item: T) => string;
453
+ filterItem: (item: T, search: string) => boolean;
454
+ }
455
+
456
+ function SelectorModal<T>({
457
+ isOpen,
458
+ onClose,
459
+ onSelect,
460
+ items,
461
+ selectedId,
462
+ title,
463
+ searchPlaceholder = 'Search...',
464
+ renderItem,
465
+ getItemId,
466
+ filterItem,
467
+ }: SelectorModalProps<T>) {
468
+ const [search, setSearch] = useState('');
469
+
470
+ // Reset search when modal opens
471
+ useEffect(() => {
472
+ if (isOpen) setSearch('');
473
+ }, [isOpen]);
474
+
475
+ const filteredItems = useMemo(() => {
476
+ if (!search.trim()) return items;
477
+ return items.filter(item => filterItem(item, search.toLowerCase()));
478
+ }, [items, search, filterItem]);
479
+
480
+ const handleSelect = (item: T) => {
481
+ onSelect(item);
482
+ onClose();
483
+ };
484
+
485
+ return (
486
+ <Modal
487
+ isOpen={isOpen}
488
+ onClose={onClose}
489
+ title={title}
490
+ size="lg"
491
+ >
492
+ {/* Search */}
493
+ <div className="mb-4">
494
+ <Input
495
+ type="text"
496
+ placeholder={searchPlaceholder}
497
+ value={search}
498
+ onChange={(e) => setSearch(e.target.value)}
499
+ autoFocus
500
+ />
501
+ </div>
502
+
503
+ {/* Results count */}
504
+ <p className="text-xs text-text-muted mb-3">
505
+ {filteredItems.length} of {items.length} items
506
+ </p>
507
+
508
+ {/* Items list */}
509
+ <div className="max-h-80 overflow-y-auto -mx-5 px-5">
510
+ {filteredItems.length === 0 ? (
511
+ <p className="text-center text-text-secondary py-8">
512
+ No items match your search
513
+ </p>
514
+ ) : (
515
+ <div className="space-y-1">
516
+ {filteredItems.map((item) => {
517
+ const id = getItemId(item);
518
+ const isSelected = id === selectedId;
519
+ return (
520
+ <button
521
+ key={id}
522
+ onClick={() => handleSelect(item)}
523
+ className={`
524
+ w-full text-left p-3 rounded-lg transition-colors
525
+ ${isSelected
526
+ ? 'bg-accent/20 border border-accent/30'
527
+ : 'hover:bg-surface-raised border border-transparent'
528
+ }
529
+ `}
530
+ >
531
+ {renderItem(item, isSelected)}
532
+ </button>
533
+ );
534
+ })}
535
+ </div>
536
+ )}
537
+ </div>
538
+ </Modal>
539
+ );
540
+ }
541
+ ```
542
+
543
+ ## Wizard Modal
544
+
545
+ ### Multi-Step Modal
546
+ ```tsx
547
+ interface WizardStep {
548
+ id: string;
549
+ title: string;
550
+ validate?: () => boolean;
551
+ }
552
+
553
+ interface WizardModalProps {
554
+ isOpen: boolean;
555
+ onClose: () => void;
556
+ onComplete: () => Promise<void>;
557
+ steps: WizardStep[];
558
+ children: (stepId: string) => ReactNode;
559
+ title: string;
560
+ }
561
+
562
+ function WizardModal({
563
+ isOpen,
564
+ onClose,
565
+ onComplete,
566
+ steps,
567
+ children,
568
+ title,
569
+ }: WizardModalProps) {
570
+ const [currentStep, setCurrentStep] = useState(0);
571
+ const [isCompleting, setIsCompleting] = useState(false);
572
+
573
+ // Reset on open
574
+ useEffect(() => {
575
+ if (isOpen) setCurrentStep(0);
576
+ }, [isOpen]);
577
+
578
+ const isFirstStep = currentStep === 0;
579
+ const isLastStep = currentStep === steps.length - 1;
580
+ const step = steps[currentStep];
581
+
582
+ const handleNext = () => {
583
+ if (step.validate && !step.validate()) return;
584
+ setCurrentStep(prev => prev + 1);
585
+ };
586
+
587
+ const handleBack = () => {
588
+ setCurrentStep(prev => prev - 1);
589
+ };
590
+
591
+ const handleComplete = async () => {
592
+ if (step.validate && !step.validate()) return;
593
+ setIsCompleting(true);
594
+ try {
595
+ await onComplete();
596
+ onClose();
597
+ } finally {
598
+ setIsCompleting(false);
599
+ }
600
+ };
601
+
602
+ return (
603
+ <Modal
604
+ isOpen={isOpen}
605
+ onClose={onClose}
606
+ title={title}
607
+ size="lg"
608
+ footer={
609
+ <>
610
+ {!isFirstStep && (
611
+ <Button variant="ghost" onClick={handleBack} disabled={isCompleting}>
612
+ Back
613
+ </Button>
614
+ )}
615
+ <div className="flex-1" />
616
+ <Button variant="ghost" onClick={onClose} disabled={isCompleting}>
617
+ Cancel
618
+ </Button>
619
+ {isLastStep ? (
620
+ <Button variant="primary" onClick={handleComplete} disabled={isCompleting}>
621
+ {isCompleting ? 'Completing...' : 'Complete'}
622
+ </Button>
623
+ ) : (
624
+ <Button variant="primary" onClick={handleNext}>
625
+ Next
626
+ </Button>
627
+ )}
628
+ </>
629
+ }
630
+ >
631
+ {/* Step indicator */}
632
+ <div className="flex items-center justify-center gap-2 mb-6">
633
+ {steps.map((s, index) => (
634
+ <div
635
+ key={s.id}
636
+ className={`
637
+ flex items-center gap-2
638
+ ${index < currentStep ? 'text-accent' : index === currentStep ? 'text-white' : 'text-text-muted'}
639
+ `}
640
+ >
641
+ <div className={`
642
+ w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
643
+ ${index < currentStep ? 'bg-accent text-white' :
644
+ index === currentStep ? 'bg-accent/20 border-2 border-accent' :
645
+ 'bg-surface-raised'}
646
+ `}>
647
+ {index < currentStep ? <CheckIcon className="w-4 h-4" /> : index + 1}
648
+ </div>
649
+ {index < steps.length - 1 && (
650
+ <div className={`w-8 h-0.5 ${index < currentStep ? 'bg-accent' : 'bg-border'}`} />
651
+ )}
652
+ </div>
653
+ ))}
654
+ </div>
655
+
656
+ {/* Step title */}
657
+ <h3 className="text-lg font-medium text-white text-center mb-6">
658
+ {step.title}
659
+ </h3>
660
+
661
+ {/* Step content */}
662
+ {children(step.id)}
663
+ </Modal>
664
+ );
665
+ }
666
+ ```
667
+
668
+ ## Accessibility Requirements
669
+
670
+ ### Focus Management
671
+ ```tsx
672
+ // Focus first interactive element on open
673
+ useEffect(() => {
674
+ if (isOpen) {
675
+ const firstFocusable = modalRef.current?.querySelector(
676
+ 'button, [href], input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'
677
+ ) as HTMLElement;
678
+ firstFocusable?.focus();
679
+ }
680
+ }, [isOpen]);
681
+
682
+ // Return focus to trigger on close
683
+ const triggerRef = useRef<HTMLElement | null>(null);
684
+
685
+ const openModal = (e: React.MouseEvent) => {
686
+ triggerRef.current = e.currentTarget as HTMLElement;
687
+ setIsOpen(true);
688
+ };
689
+
690
+ const closeModal = () => {
691
+ setIsOpen(false);
692
+ triggerRef.current?.focus();
693
+ };
694
+ ```
695
+
696
+ ### ARIA Attributes
697
+ ```tsx
698
+ <div
699
+ role="dialog"
700
+ aria-modal="true"
701
+ aria-labelledby="modal-title"
702
+ aria-describedby="modal-description"
703
+ >
704
+ ```
705
+
706
+ ## Animation Patterns
707
+
708
+ ### Enter/Exit Animations
709
+ ```css
710
+ /* Tailwind animation utilities */
711
+ @keyframes fadeIn {
712
+ from { opacity: 0; }
713
+ to { opacity: 1; }
714
+ }
715
+
716
+ @keyframes zoomIn {
717
+ from { opacity: 0; transform: scale(0.95); }
718
+ to { opacity: 1; transform: scale(1); }
719
+ }
720
+
721
+ .animate-in {
722
+ animation: fadeIn 200ms ease-out, zoomIn 200ms ease-out;
723
+ }
724
+ ```
725
+
726
+ ## Audit Checklist for Modals
727
+
728
+ ### Critical (Must Fix)
729
+ - [ ] Traps focus within modal - accessibility violation, focus escapes
730
+ - [ ] Closes on Escape key - accessibility violation
731
+ - [ ] Has proper ARIA attributes (role, aria-modal, aria-labelledby) - screen readers can't announce
732
+ - [ ] Returns focus to trigger on close - focus gets lost
733
+
734
+ ### Major (Should Fix)
735
+ - [ ] Has clear title describing action - users confused about context
736
+ - [ ] Has close button (X) in header - no obvious way to dismiss
737
+ - [ ] Body scroll is locked when open - background scrolls unexpectedly
738
+ - [ ] Confirmation modals show item name - users unsure what they're deleting
739
+ - [ ] Delete modals have warning styling - destructive action not obvious
740
+ - [ ] Edit modals warn about unsaved changes - data loss risk
741
+ - [ ] Loading states disable all actions - double submissions
742
+
743
+ ### Minor (Nice to Have)
744
+ - [ ] Closes on backdrop click (unless editing) - convenience
745
+ - [ ] Edit modals reset on open - stale data visible briefly
746
+ - [ ] Footer buttons are right-aligned - consistency
747
+ - [ ] Primary action matches intent (danger for delete) - visual clarity
748
+ - [ ] Footer buttons are right-aligned
749
+ - [ ] Cancel is secondary/ghost style
750
+ - [ ] Primary action matches intent (danger for delete)