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,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)
|