@discourser/design-system 0.3.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.
@@ -0,0 +1,465 @@
1
+ # Dialog
2
+
3
+ **Purpose:** Modal overlay component for focused tasks, confirmations, and important information following Material Design 3 patterns.
4
+
5
+ ## Import
6
+
7
+ ```typescript
8
+ import { Dialog } from '@discourser/design-system';
9
+ ```
10
+
11
+ ## Overview
12
+
13
+ The Dialog component creates modal overlays that:
14
+ - Block interaction with the background content
15
+ - Display a semi-transparent backdrop (scrim)
16
+ - Center content on screen
17
+ - Support keyboard navigation (Escape to close)
18
+ - Manage focus automatically
19
+ - Prevent scroll on background content
20
+
21
+ ## Sizes
22
+
23
+ | Size | Width | Min Height | Usage |
24
+ |------|-------|-----------|-------|
25
+ | `sm` | 280px | 140px | Simple confirmations, alerts |
26
+ | `md` | 560px | 200px | Default, most dialogs |
27
+ | `lg` | 800px | 300px | Forms, detailed content |
28
+ | `fullscreen` | 100vw × 100vh | - | Mobile-optimized, complex flows |
29
+
30
+ ## Props
31
+
32
+ | Prop | Type | Default | Description |
33
+ |------|------|---------|-------------|
34
+ | `open` | `boolean` | - | Whether the dialog is open (controlled) |
35
+ | `onOpenChange` | `(details: { open: boolean }) => void` | - | Callback when open state changes |
36
+ | `title` | `string` | - | Dialog title (headlineSmall) |
37
+ | `description` | `string` | - | Dialog description/content (bodyMedium) |
38
+ | `children` | `ReactNode` | - | Custom dialog content (alternative to description) |
39
+ | `size` | `'sm' \| 'md' \| 'lg' \| 'fullscreen'` | `'md'` | Dialog size |
40
+ | `showCloseButton` | `boolean` | `true` | Whether to show the close button |
41
+ | `closeLabel` | `string` | `'Close'` | Accessible label for close button |
42
+
43
+ ## Examples
44
+
45
+ ### Basic Usage
46
+
47
+ ```typescript
48
+ const [open, setOpen] = useState(false);
49
+
50
+ // Simple confirmation dialog
51
+ <>
52
+ <Button onClick={() => setOpen(true)}>Open Dialog</Button>
53
+
54
+ <Dialog
55
+ open={open}
56
+ onOpenChange={({ open }) => setOpen(open)}
57
+ title="Confirm Action"
58
+ description="Are you sure you want to proceed? This action cannot be undone."
59
+ />
60
+ </>
61
+ ```
62
+
63
+ ### With Custom Content
64
+
65
+ ```typescript
66
+ const [open, setOpen] = useState(false);
67
+
68
+ <Dialog
69
+ open={open}
70
+ onOpenChange={({ open }) => setOpen(open)}
71
+ title="Create New Project"
72
+ size="md"
73
+ >
74
+ <div className={css({ p: 'lg', display: 'flex', flexDirection: 'column', gap: 'md' })}>
75
+ <Input label="Project Name" />
76
+ <Input label="Description" />
77
+ <div className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end', mt: 'md' })}>
78
+ <Button variant="text" onClick={() => setOpen(false)}>
79
+ Cancel
80
+ </Button>
81
+ <Button variant="filled" onClick={handleCreate}>
82
+ Create
83
+ </Button>
84
+ </div>
85
+ </div>
86
+ </Dialog>
87
+ ```
88
+
89
+ ### Different Sizes
90
+
91
+ ```typescript
92
+ // Small (alerts, confirmations)
93
+ <Dialog
94
+ open={open}
95
+ onOpenChange={({ open }) => setOpen(open)}
96
+ size="sm"
97
+ title="Delete Item?"
98
+ description="This action cannot be undone."
99
+ />
100
+
101
+ // Medium (default)
102
+ <Dialog
103
+ open={open}
104
+ onOpenChange={({ open }) => setOpen(open)}
105
+ size="md"
106
+ title="Edit Profile"
107
+ >
108
+ {/* Form content */}
109
+ </Dialog>
110
+
111
+ // Large (forms, detailed content)
112
+ <Dialog
113
+ open={open}
114
+ onOpenChange={({ open }) => setOpen(open)}
115
+ size="lg"
116
+ title="Settings"
117
+ >
118
+ {/* Complex settings UI */}
119
+ </Dialog>
120
+
121
+ // Fullscreen (mobile-optimized)
122
+ <Dialog
123
+ open={open}
124
+ onOpenChange={({ open }) => setOpen(open)}
125
+ size="fullscreen"
126
+ title="Full Editor"
127
+ >
128
+ {/* Full-page editor */}
129
+ </Dialog>
130
+ ```
131
+
132
+ ### Without Close Button
133
+
134
+ ```typescript
135
+ <Dialog
136
+ open={open}
137
+ onOpenChange={({ open }) => setOpen(open)}
138
+ title="Please Wait"
139
+ description="Processing your request..."
140
+ showCloseButton={false}
141
+ />
142
+ ```
143
+
144
+ ## Common Patterns
145
+
146
+ ### Confirmation Dialog
147
+
148
+ ```typescript
149
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
150
+ const [isDeleting, setIsDeleting] = useState(false);
151
+
152
+ const handleDelete = async () => {
153
+ setIsDeleting(true);
154
+ await deleteItem();
155
+ setIsDeleting(false);
156
+ setDeleteDialogOpen(false);
157
+ };
158
+
159
+ <Dialog
160
+ open={deleteDialogOpen}
161
+ onOpenChange={({ open }) => setDeleteDialogOpen(open)}
162
+ title="Delete Item?"
163
+ size="sm"
164
+ >
165
+ <div className={css({ p: 'lg' })}>
166
+ <p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant', mb: 'lg' })}>
167
+ This action cannot be undone. The item will be permanently deleted.
168
+ </p>
169
+ <div className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end' })}>
170
+ <Button variant="text" onClick={() => setDeleteDialogOpen(false)}>
171
+ Cancel
172
+ </Button>
173
+ <Button variant="filled" onClick={handleDelete} disabled={isDeleting}>
174
+ {isDeleting ? 'Deleting...' : 'Delete'}
175
+ </Button>
176
+ </div>
177
+ </div>
178
+ </Dialog>
179
+ ```
180
+
181
+ ### Form Dialog
182
+
183
+ ```typescript
184
+ const [open, setOpen] = useState(false);
185
+ const [formData, setFormData] = useState({ name: '', email: '' });
186
+
187
+ const handleSubmit = (e: FormEvent) => {
188
+ e.preventDefault();
189
+ // Process form
190
+ setOpen(false);
191
+ };
192
+
193
+ <Dialog
194
+ open={open}
195
+ onOpenChange={({ open }) => setOpen(open)}
196
+ title="Contact Information"
197
+ size="md"
198
+ >
199
+ <form onSubmit={handleSubmit}>
200
+ <div className={css({ p: 'lg', display: 'flex', flexDirection: 'column', gap: 'md' })}>
201
+ <Input
202
+ label="Name"
203
+ value={formData.name}
204
+ onChange={(e) => setFormData({ ...formData, name: e.target.value })}
205
+ required
206
+ />
207
+ <Input
208
+ label="Email"
209
+ type="email"
210
+ value={formData.email}
211
+ onChange={(e) => setFormData({ ...formData, email: e.target.value })}
212
+ required
213
+ />
214
+ <div className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end', mt: 'md' })}>
215
+ <Button type="button" variant="text" onClick={() => setOpen(false)}>
216
+ Cancel
217
+ </Button>
218
+ <Button type="submit" variant="filled">
219
+ Save
220
+ </Button>
221
+ </div>
222
+ </div>
223
+ </form>
224
+ </Dialog>
225
+ ```
226
+
227
+ ### Alert Dialog
228
+
229
+ ```typescript
230
+ <Dialog
231
+ open={alertOpen}
232
+ onOpenChange={({ open }) => setAlertOpen(open)}
233
+ title="Success!"
234
+ size="sm"
235
+ >
236
+ <div className={css({ p: 'lg' })}>
237
+ <p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant', mb: 'lg' })}>
238
+ Your changes have been saved successfully.
239
+ </p>
240
+ <div className={css({ display: 'flex', justifyContent: 'flex-end' })}>
241
+ <Button variant="filled" onClick={() => setAlertOpen(false)}>
242
+ OK
243
+ </Button>
244
+ </div>
245
+ </div>
246
+ </Dialog>
247
+ ```
248
+
249
+ ### Loading Dialog
250
+
251
+ ```typescript
252
+ <Dialog
253
+ open={loading}
254
+ onOpenChange={() => {}} // Cannot close while loading
255
+ title="Processing"
256
+ showCloseButton={false}
257
+ size="sm"
258
+ >
259
+ <div className={css({ p: 'lg', textAlign: 'center' })}>
260
+ <Spinner className={css({ mb: 'md' })} />
261
+ <p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
262
+ Please wait while we process your request...
263
+ </p>
264
+ </div>
265
+ </Dialog>
266
+ ```
267
+
268
+ ## DO NOT
269
+
270
+ ```typescript
271
+ // ❌ Don't use div or custom modal (use Dialog component)
272
+ <div className="modal-overlay">
273
+ <div className="modal-content">...</div>
274
+ </div> // Use <Dialog> instead
275
+
276
+ // ❌ Don't forget controlled state
277
+ <Dialog title="Test">...</Dialog> // Missing open/onOpenChange
278
+
279
+ // ✅ Always control the dialog state
280
+ <Dialog
281
+ open={open}
282
+ onOpenChange={({ open }) => setOpen(open)}
283
+ title="Test"
284
+ >
285
+ ...
286
+ </Dialog>
287
+
288
+ // ❌ Don't nest dialogs
289
+ <Dialog open={open1}>
290
+ <Dialog open={open2}>...</Dialog> // Avoid nested dialogs
291
+ </Dialog>
292
+
293
+ // ❌ Don't override backdrop/scrim styles heavily
294
+ <Dialog
295
+ style={{ backgroundColor: 'red' }} // Don't do this
296
+ title="Test"
297
+ />
298
+
299
+ // ❌ Don't use fullscreen for simple confirmations
300
+ <Dialog size="fullscreen" title="Delete item?"> // Overkill
301
+ ...
302
+ </Dialog>
303
+
304
+ // ✅ Use appropriate sizes
305
+ <Dialog size="sm" title="Delete item?">
306
+ ...
307
+ </Dialog>
308
+ ```
309
+
310
+ ## Accessibility
311
+
312
+ The Dialog component follows WCAG 2.1 Level AA standards:
313
+
314
+ - **Focus Management**: Automatically traps focus within dialog
315
+ - **Keyboard Navigation**: Escape key closes dialog, Tab cycles through elements
316
+ - **ARIA Attributes**: Proper role, aria-modal, aria-labelledby applied
317
+ - **Focus Restoration**: Returns focus to trigger element on close
318
+ - **Screen Reader Support**: Title and description properly associated
319
+
320
+ ### Accessibility Best Practices
321
+
322
+ ```typescript
323
+ // ✅ Always provide a title
324
+ <Dialog
325
+ open={open}
326
+ onOpenChange={({ open }) => setOpen(open)}
327
+ title="Confirm Deletion" // Required for accessibility
328
+ >
329
+ ...
330
+ </Dialog>
331
+
332
+ // ✅ Provide descriptive close button label
333
+ <Dialog
334
+ open={open}
335
+ onOpenChange={({ open }) => setOpen(open)}
336
+ title="Settings"
337
+ closeLabel="Close settings dialog"
338
+ >
339
+ ...
340
+ </Dialog>
341
+
342
+ // ✅ Use semantic button elements for actions
343
+ <Dialog open={open} onOpenChange={({ open }) => setOpen(open)} title="Confirm">
344
+ <div className={css({ p: 'lg' })}>
345
+ <p>Are you sure?</p>
346
+ <Button variant="text">Cancel</Button> {/* Semantic button */}
347
+ <Button variant="filled">Confirm</Button>
348
+ </div>
349
+ </Dialog>
350
+
351
+ // ✅ Ensure proper reading order
352
+ // Dialog content should follow logical reading order (title → description → actions)
353
+ ```
354
+
355
+ ## Size Selection Guide
356
+
357
+ | Scenario | Recommended Size | Reasoning |
358
+ |----------|-----------------|-----------|
359
+ | Simple confirmation | `sm` | Minimal content, quick decision |
360
+ | Alerts | `sm` | Brief message, single action |
361
+ | Forms (2-3 fields) | `md` | Standard forms, most common |
362
+ | Settings/Preferences | `lg` | Multiple sections, complex UI |
363
+ | Mobile editor/viewer | `fullscreen` | Maximize screen space |
364
+ | Multi-step wizard | `lg` or `fullscreen` | Complex flow needs space |
365
+
366
+ ## State Behaviors
367
+
368
+ | State | Visual Change | Behavior |
369
+ |-------|---------------|----------|
370
+ | **Opening** | Fade in + scale animation | Backdrop fades, content scales up |
371
+ | **Open** | Fully visible | Modal state, focus trapped |
372
+ | **Closing** | Fade out + scale animation | Backdrop fades, content scales down |
373
+ | **Backdrop Click** | Closes dialog | Click outside closes (default Ark UI behavior) |
374
+ | **Escape Key** | Closes dialog | Keyboard shortcut to close |
375
+
376
+ ## Backdrop Behavior
377
+
378
+ The backdrop (scrim) behind the dialog:
379
+ - Uses `scrim` color token (#000000)
380
+ - 40% opacity
381
+ - Blocks all background interactions
382
+ - Clicking backdrop closes dialog (can be disabled via Ark UI props)
383
+
384
+ ## Focus Management
385
+
386
+ The Dialog component automatically:
387
+ 1. **Traps focus** within the dialog when open
388
+ 2. **Focuses first focusable element** when opened (typically close button or first input)
389
+ 3. **Restores focus** to the trigger element when closed
390
+ 4. **Prevents Tab** from leaving the dialog
391
+
392
+ ## Responsive Considerations
393
+
394
+ ```typescript
395
+ // Mobile: Use fullscreen for complex dialogs
396
+ <Dialog
397
+ size={{ base: 'fullscreen', md: 'md' }}
398
+ open={open}
399
+ onOpenChange={({ open }) => setOpen(open)}
400
+ title="Settings"
401
+ >
402
+ {/* Complex settings */}
403
+ </Dialog>
404
+
405
+ // Mobile: Reduce to sm for simple dialogs
406
+ <Dialog
407
+ size={{ base: 'sm', md: 'md' }}
408
+ open={open}
409
+ onOpenChange={({ open }) => setOpen(open)}
410
+ title="Confirm"
411
+ >
412
+ {/* Simple confirmation */}
413
+ </Dialog>
414
+ ```
415
+
416
+ ## Testing
417
+
418
+ ```typescript
419
+ import { render, screen } from '@testing-library/react';
420
+ import userEvent from '@testing-library/user-event';
421
+
422
+ test('dialog opens and closes', async () => {
423
+ const { rerender } = render(
424
+ <Dialog open={false} onOpenChange={() => {}} title="Test">
425
+ Dialog content
426
+ </Dialog>
427
+ );
428
+
429
+ expect(screen.queryByText('Dialog content')).not.toBeInTheDocument();
430
+
431
+ rerender(
432
+ <Dialog open={true} onOpenChange={() => {}} title="Test">
433
+ Dialog content
434
+ </Dialog>
435
+ );
436
+
437
+ expect(screen.getByText('Dialog content')).toBeInTheDocument();
438
+ });
439
+
440
+ test('dialog close button triggers onOpenChange', async () => {
441
+ const handleOpenChange = vi.fn();
442
+ render(
443
+ <Dialog open={true} onOpenChange={handleOpenChange} title="Test">
444
+ Content
445
+ </Dialog>
446
+ );
447
+
448
+ const closeButton = screen.getByLabelText('Close');
449
+ await userEvent.click(closeButton);
450
+
451
+ expect(handleOpenChange).toHaveBeenCalledWith({ open: false });
452
+ });
453
+
454
+ test('dialog has proper ARIA attributes', () => {
455
+ render(
456
+ <Dialog open={true} onOpenChange={() => {}} title="Test Dialog">
457
+ Content
458
+ </Dialog>
459
+ );
460
+
461
+ const dialog = screen.getByRole('dialog');
462
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
463
+ expect(screen.getByText('Test Dialog')).toBeInTheDocument();
464
+ });
465
+ ```