@discourser/design-system 0.4.0 → 0.5.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 +12 -4
- package/dist/styles.css +5126 -0
- package/guidelines/Guidelines.md +67 -123
- package/guidelines/components/accordion.md +93 -0
- package/guidelines/components/avatar.md +70 -0
- package/guidelines/components/badge.md +61 -0
- package/guidelines/components/button.md +75 -40
- package/guidelines/components/card.md +84 -25
- package/guidelines/components/checkbox.md +88 -0
- package/guidelines/components/dialog.md +619 -31
- package/guidelines/components/drawer.md +655 -0
- package/guidelines/components/heading.md +71 -0
- package/guidelines/components/icon-button.md +92 -37
- package/guidelines/components/input-addon.md +685 -0
- package/guidelines/components/input-group.md +830 -0
- package/guidelines/components/input.md +92 -37
- package/guidelines/components/popover.md +71 -0
- package/guidelines/components/progress.md +63 -0
- package/guidelines/components/radio-group.md +95 -0
- package/guidelines/components/select.md +507 -0
- package/guidelines/components/skeleton.md +76 -0
- package/guidelines/components/slider.md +911 -0
- package/guidelines/components/spinner.md +783 -0
- package/guidelines/components/switch.md +105 -38
- package/guidelines/components/tabs.md +654 -0
- package/guidelines/components/textarea.md +70 -0
- package/guidelines/components/toast.md +77 -0
- package/guidelines/components/tooltip.md +80 -0
- package/guidelines/design-tokens/colors.md +309 -72
- package/guidelines/design-tokens/elevation.md +615 -45
- package/guidelines/design-tokens/spacing.md +654 -74
- package/guidelines/design-tokens/typography.md +432 -50
- package/guidelines/overview-components.md +9 -5
- package/guidelines/overview-imports.md +314 -0
- package/guidelines/overview-patterns.md +3852 -0
- package/package.json +4 -2
|
@@ -2,6 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
**Purpose:** Modal overlay component for focused tasks, confirmations, and important information following Material Design 3 patterns.
|
|
4
4
|
|
|
5
|
+
## When to Use This Component
|
|
6
|
+
|
|
7
|
+
Use Dialog when you need to **interrupt the user's workflow** and demand their attention for a critical task, decision, or information.
|
|
8
|
+
|
|
9
|
+
**Decision Tree:**
|
|
10
|
+
|
|
11
|
+
| Scenario | Use This | Why |
|
|
12
|
+
| ---------------------------------------------------------- | --------- | ----------------------------------------- |
|
|
13
|
+
| Critical confirmation (delete, logout, destructive action) | Dialog ✅ | Blocks interaction until user responds |
|
|
14
|
+
| Form submission requiring focus (create account, add item) | Dialog ✅ | Prevents distraction, modal context |
|
|
15
|
+
| Important notifications requiring acknowledgment | Dialog ✅ | User must explicitly dismiss |
|
|
16
|
+
| Side navigation or supplementary content | Drawer | Doesn't block page, slides from edge |
|
|
17
|
+
| Contextual information near trigger element | Popover | Non-modal, positioned relative to trigger |
|
|
18
|
+
| Brief help text (under 2 sentences) | Tooltip | Lightweight, appears on hover |
|
|
19
|
+
| Content grouping without interruption | Card | Non-modal, embedded in page flow |
|
|
20
|
+
|
|
21
|
+
**Component Comparison:**
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// ✅ Use Dialog for critical confirmations
|
|
25
|
+
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
26
|
+
<Dialog.Trigger>
|
|
27
|
+
<Button variant="filled">Delete Account</Button>
|
|
28
|
+
</Dialog.Trigger>
|
|
29
|
+
<Dialog.Content>
|
|
30
|
+
<Dialog.Title>Delete Account?</Dialog.Title>
|
|
31
|
+
<Dialog.Description>This action cannot be undone.</Dialog.Description>
|
|
32
|
+
<Dialog.CloseTrigger>
|
|
33
|
+
<Button variant="text">Cancel</Button>
|
|
34
|
+
</Dialog.CloseTrigger>
|
|
35
|
+
</Dialog.Content>
|
|
36
|
+
</Dialog.Root>
|
|
37
|
+
|
|
38
|
+
// ❌ Don't use Dialog for navigation - use Drawer
|
|
39
|
+
<Dialog.Root>
|
|
40
|
+
<Dialog.Content>
|
|
41
|
+
<nav>
|
|
42
|
+
<Link href="/dashboard">Dashboard</Link>
|
|
43
|
+
<Link href="/settings">Settings</Link>
|
|
44
|
+
</nav>
|
|
45
|
+
</Dialog.Content>
|
|
46
|
+
</Dialog.Root> // Wrong - navigation shouldn't be modal
|
|
47
|
+
|
|
48
|
+
<Drawer.Root>
|
|
49
|
+
<Drawer.Content>
|
|
50
|
+
<nav>
|
|
51
|
+
<Link href="/dashboard">Dashboard</Link>
|
|
52
|
+
<Link href="/settings">Settings</Link>
|
|
53
|
+
</nav>
|
|
54
|
+
</Drawer.Content>
|
|
55
|
+
</Drawer.Root> // Correct
|
|
56
|
+
|
|
57
|
+
// ❌ Don't use Dialog for contextual help - use Popover or Tooltip
|
|
58
|
+
<Dialog.Root>
|
|
59
|
+
<Dialog.Content>
|
|
60
|
+
<Dialog.Description>Click here to save your work</Dialog.Description>
|
|
61
|
+
</Dialog.Content>
|
|
62
|
+
</Dialog.Root> // Wrong - too heavy for simple help
|
|
63
|
+
|
|
64
|
+
<Tooltip.Root>
|
|
65
|
+
<Tooltip.Trigger><Button>Save</Button></Tooltip.Trigger>
|
|
66
|
+
<Tooltip.Content>Click here to save your work</Tooltip.Content>
|
|
67
|
+
</Tooltip.Root> // Correct
|
|
68
|
+
```
|
|
69
|
+
|
|
5
70
|
## Import
|
|
6
71
|
|
|
7
72
|
```typescript
|
|
@@ -11,6 +76,7 @@ import { Dialog } from '@discourser/design-system';
|
|
|
11
76
|
## Overview
|
|
12
77
|
|
|
13
78
|
The Dialog component creates modal overlays that:
|
|
79
|
+
|
|
14
80
|
- Block interaction with the background content
|
|
15
81
|
- Display a semi-transparent backdrop (scrim)
|
|
16
82
|
- Center content on screen
|
|
@@ -20,25 +86,25 @@ The Dialog component creates modal overlays that:
|
|
|
20
86
|
|
|
21
87
|
## Sizes
|
|
22
88
|
|
|
23
|
-
| Size
|
|
24
|
-
|
|
25
|
-
| `sm`
|
|
26
|
-
| `md`
|
|
27
|
-
| `lg`
|
|
28
|
-
| `fullscreen` | 100vw × 100vh | -
|
|
89
|
+
| Size | Width | Min Height | Usage |
|
|
90
|
+
| ------------ | ------------- | ---------- | ------------------------------- |
|
|
91
|
+
| `sm` | 280px | 140px | Simple confirmations, alerts |
|
|
92
|
+
| `md` | 560px | 200px | Default, most dialogs |
|
|
93
|
+
| `lg` | 800px | 300px | Forms, detailed content |
|
|
94
|
+
| `fullscreen` | 100vw × 100vh | - | Mobile-optimized, complex flows |
|
|
29
95
|
|
|
30
96
|
## Props
|
|
31
97
|
|
|
32
|
-
| Prop
|
|
33
|
-
|
|
34
|
-
| `open`
|
|
35
|
-
| `onOpenChange`
|
|
36
|
-
| `title`
|
|
37
|
-
| `description`
|
|
38
|
-
| `children`
|
|
39
|
-
| `size`
|
|
40
|
-
| `showCloseButton` | `boolean`
|
|
41
|
-
| `closeLabel`
|
|
98
|
+
| Prop | Type | Default | Description |
|
|
99
|
+
| ----------------- | -------------------------------------- | --------- | -------------------------------------------------- |
|
|
100
|
+
| `open` | `boolean` | - | Whether the dialog is open (controlled) |
|
|
101
|
+
| `onOpenChange` | `(details: { open: boolean }) => void` | - | Callback when open state changes |
|
|
102
|
+
| `title` | `string` | - | Dialog title (headlineSmall) |
|
|
103
|
+
| `description` | `string` | - | Dialog description/content (bodyMedium) |
|
|
104
|
+
| `children` | `ReactNode` | - | Custom dialog content (alternative to description) |
|
|
105
|
+
| `size` | `'sm' \| 'md' \| 'lg' \| 'fullscreen'` | `'md'` | Dialog size |
|
|
106
|
+
| `showCloseButton` | `boolean` | `true` | Whether to show the close button |
|
|
107
|
+
| `closeLabel` | `string` | `'Close'` | Accessible label for close button |
|
|
42
108
|
|
|
43
109
|
## Examples
|
|
44
110
|
|
|
@@ -265,6 +331,526 @@ const handleSubmit = (e: FormEvent) => {
|
|
|
265
331
|
</Dialog>
|
|
266
332
|
```
|
|
267
333
|
|
|
334
|
+
## Edge Cases
|
|
335
|
+
|
|
336
|
+
This section covers common edge cases and how to handle them properly.
|
|
337
|
+
|
|
338
|
+
### Nested Dialogs - Confirmation Flow
|
|
339
|
+
|
|
340
|
+
**Scenario:** A dialog needs to open another dialog for confirmation (e.g., delete confirmation within a settings dialog).
|
|
341
|
+
|
|
342
|
+
**Solution:**
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
346
|
+
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
347
|
+
|
|
348
|
+
const handleDelete = () => {
|
|
349
|
+
// Show confirmation dialog on top of settings dialog
|
|
350
|
+
setConfirmDeleteOpen(true);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const confirmDelete = async () => {
|
|
354
|
+
// Perform delete operation
|
|
355
|
+
await deleteAccount();
|
|
356
|
+
// Close both dialogs
|
|
357
|
+
setConfirmDeleteOpen(false);
|
|
358
|
+
setSettingsOpen(false);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
<>
|
|
362
|
+
{/* Primary settings dialog */}
|
|
363
|
+
<Dialog
|
|
364
|
+
open={settingsOpen}
|
|
365
|
+
onOpenChange={({ open }) => setSettingsOpen(open)}
|
|
366
|
+
title="Account Settings"
|
|
367
|
+
size="md"
|
|
368
|
+
>
|
|
369
|
+
<div className={css({ p: 'lg' })}>
|
|
370
|
+
<h3 className={css({ textStyle: 'bodyLarge', mb: 'md' })}>Danger Zone</h3>
|
|
371
|
+
<Button
|
|
372
|
+
variant="outlined"
|
|
373
|
+
colorPalette="error"
|
|
374
|
+
onClick={handleDelete}
|
|
375
|
+
>
|
|
376
|
+
Delete Account
|
|
377
|
+
</Button>
|
|
378
|
+
</div>
|
|
379
|
+
</Dialog>
|
|
380
|
+
|
|
381
|
+
{/* Nested confirmation dialog */}
|
|
382
|
+
<Dialog
|
|
383
|
+
open={confirmDeleteOpen}
|
|
384
|
+
onOpenChange={({ open }) => setConfirmDeleteOpen(open)}
|
|
385
|
+
title="Confirm Deletion"
|
|
386
|
+
size="sm"
|
|
387
|
+
>
|
|
388
|
+
<div className={css({ p: 'lg' })}>
|
|
389
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant', mb: 'lg' })}>
|
|
390
|
+
Are you absolutely sure? This action cannot be undone.
|
|
391
|
+
All your data will be permanently deleted.
|
|
392
|
+
</p>
|
|
393
|
+
<div className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end' })}>
|
|
394
|
+
<Button variant="text" onClick={() => setConfirmDeleteOpen(false)}>
|
|
395
|
+
Cancel
|
|
396
|
+
</Button>
|
|
397
|
+
<Button variant="filled" colorPalette="error" onClick={confirmDelete}>
|
|
398
|
+
Delete Forever
|
|
399
|
+
</Button>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
</Dialog>
|
|
403
|
+
</>
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
**Best practices:**
|
|
407
|
+
|
|
408
|
+
- Limit nesting to two levels maximum to avoid overwhelming users
|
|
409
|
+
- Use smaller dialog sizes for nested confirmations (typically `sm`)
|
|
410
|
+
- Ensure both dialogs can be dismissed independently via Escape key
|
|
411
|
+
- Consider closing both dialogs after the nested action completes
|
|
412
|
+
- Use proper z-index stacking (handled automatically by Dialog component)
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
### Focus Trap Edge Cases
|
|
417
|
+
|
|
418
|
+
**Scenario:** Managing focus when a dialog opens with complex focus requirements or when there are no focusable elements.
|
|
419
|
+
|
|
420
|
+
**Solution:**
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
import { useRef, useEffect } from 'react';
|
|
424
|
+
|
|
425
|
+
const [open, setOpen] = useState(false);
|
|
426
|
+
const firstInputRef = useRef<HTMLInputElement>(null);
|
|
427
|
+
|
|
428
|
+
// Focus first input when dialog opens
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
if (open && firstInputRef.current) {
|
|
431
|
+
// Small delay to ensure dialog animation completes
|
|
432
|
+
setTimeout(() => {
|
|
433
|
+
firstInputRef.current?.focus();
|
|
434
|
+
}, 100);
|
|
435
|
+
}
|
|
436
|
+
}, [open]);
|
|
437
|
+
|
|
438
|
+
<Dialog
|
|
439
|
+
open={open}
|
|
440
|
+
onOpenChange={({ open }) => setOpen(open)}
|
|
441
|
+
title="New Contact"
|
|
442
|
+
size="md"
|
|
443
|
+
>
|
|
444
|
+
<form onSubmit={handleSubmit}>
|
|
445
|
+
<div className={css({ p: 'lg', display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
446
|
+
{/* Explicitly focus first input */}
|
|
447
|
+
<Input
|
|
448
|
+
ref={firstInputRef}
|
|
449
|
+
label="Name"
|
|
450
|
+
placeholder="Enter name"
|
|
451
|
+
required
|
|
452
|
+
autoComplete="name"
|
|
453
|
+
/>
|
|
454
|
+
<Input
|
|
455
|
+
label="Email"
|
|
456
|
+
type="email"
|
|
457
|
+
placeholder="Enter email"
|
|
458
|
+
required
|
|
459
|
+
autoComplete="email"
|
|
460
|
+
/>
|
|
461
|
+
|
|
462
|
+
{/* Ensure dialog has focusable elements */}
|
|
463
|
+
<div className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end', mt: 'md' })}>
|
|
464
|
+
<Button
|
|
465
|
+
type="button"
|
|
466
|
+
variant="text"
|
|
467
|
+
onClick={() => setOpen(false)}
|
|
468
|
+
>
|
|
469
|
+
Cancel
|
|
470
|
+
</Button>
|
|
471
|
+
<Button type="submit" variant="filled">
|
|
472
|
+
Save Contact
|
|
473
|
+
</Button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
</form>
|
|
477
|
+
</Dialog>
|
|
478
|
+
|
|
479
|
+
{/* Dialog with no interactive elements - add explicit close button */}
|
|
480
|
+
<Dialog
|
|
481
|
+
open={loadingOpen}
|
|
482
|
+
onOpenChange={() => {}} // Prevent closing during loading
|
|
483
|
+
title="Processing"
|
|
484
|
+
showCloseButton={false} // Hide default close button during loading
|
|
485
|
+
size="sm"
|
|
486
|
+
>
|
|
487
|
+
<div className={css({ p: 'lg', textAlign: 'center' })}>
|
|
488
|
+
<Spinner className={css({ mb: 'md' })} />
|
|
489
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
490
|
+
Please wait...
|
|
491
|
+
</p>
|
|
492
|
+
{/* No focusable elements - focus trapped on dialog container */}
|
|
493
|
+
</div>
|
|
494
|
+
</Dialog>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**Best practices:**
|
|
498
|
+
|
|
499
|
+
- Always include at least one focusable element in dialogs
|
|
500
|
+
- Use `autoFocus` prop or refs to control initial focus
|
|
501
|
+
- Provide clear keyboard navigation between form fields
|
|
502
|
+
- For loading states without buttons, consider making the dialog container focusable
|
|
503
|
+
- Test focus behavior with screen readers
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
### Async Loading - Dialog with Dynamic Content
|
|
508
|
+
|
|
509
|
+
**Scenario:** Dialog content needs to be loaded asynchronously after opening, requiring loading states and error handling.
|
|
510
|
+
|
|
511
|
+
**Solution:**
|
|
512
|
+
|
|
513
|
+
```typescript
|
|
514
|
+
const [open, setOpen] = useState(false);
|
|
515
|
+
const [userData, setUserData] = useState<User | null>(null);
|
|
516
|
+
const [loading, setLoading] = useState(false);
|
|
517
|
+
const [error, setError] = useState<string | null>(null);
|
|
518
|
+
|
|
519
|
+
// Load data when dialog opens
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
if (open) {
|
|
522
|
+
const loadUserData = async () => {
|
|
523
|
+
setLoading(true);
|
|
524
|
+
setError(null);
|
|
525
|
+
try {
|
|
526
|
+
const response = await fetch('/api/user/profile');
|
|
527
|
+
if (!response.ok) throw new Error('Failed to load profile');
|
|
528
|
+
const data = await response.json();
|
|
529
|
+
setUserData(data);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
532
|
+
} finally {
|
|
533
|
+
setLoading(false);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
loadUserData();
|
|
537
|
+
} else {
|
|
538
|
+
// Reset state when dialog closes
|
|
539
|
+
setUserData(null);
|
|
540
|
+
setError(null);
|
|
541
|
+
}
|
|
542
|
+
}, [open]);
|
|
543
|
+
|
|
544
|
+
<Dialog
|
|
545
|
+
open={open}
|
|
546
|
+
onOpenChange={({ open }) => setOpen(open)}
|
|
547
|
+
title="User Profile"
|
|
548
|
+
size="md"
|
|
549
|
+
>
|
|
550
|
+
<div className={css({ p: 'lg', minHeight: '200px' })}>
|
|
551
|
+
{loading && (
|
|
552
|
+
<div className={css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'md' })}>
|
|
553
|
+
<Spinner />
|
|
554
|
+
<p className={css({ textStyle: 'bodyMedium', color: 'onSurfaceVariant' })}>
|
|
555
|
+
Loading profile...
|
|
556
|
+
</p>
|
|
557
|
+
</div>
|
|
558
|
+
)}
|
|
559
|
+
|
|
560
|
+
{error && (
|
|
561
|
+
<div className={css({ textAlign: 'center' })}>
|
|
562
|
+
<p className={css({ color: 'error.fg', mb: 'md' })}>{error}</p>
|
|
563
|
+
<Button variant="outlined" onClick={() => setOpen(false)}>
|
|
564
|
+
Close
|
|
565
|
+
</Button>
|
|
566
|
+
</div>
|
|
567
|
+
)}
|
|
568
|
+
|
|
569
|
+
{!loading && !error && userData && (
|
|
570
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
571
|
+
<Input label="Name" defaultValue={userData.name} />
|
|
572
|
+
<Input label="Email" defaultValue={userData.email} />
|
|
573
|
+
<div className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end', mt: 'md' })}>
|
|
574
|
+
<Button variant="text" onClick={() => setOpen(false)}>
|
|
575
|
+
Cancel
|
|
576
|
+
</Button>
|
|
577
|
+
<Button variant="filled">Save Changes</Button>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
582
|
+
</Dialog>
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
**Best practices:**
|
|
586
|
+
|
|
587
|
+
- Show loading indicators immediately when content is loading
|
|
588
|
+
- Provide clear error messages with recovery options
|
|
589
|
+
- Set minimum heights to prevent layout shifts during loading
|
|
590
|
+
- Reset dialog state when closing to ensure fresh data on next open
|
|
591
|
+
- Consider skeleton loaders for better perceived performance
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
### Form Validation - Dialog with Form Errors
|
|
596
|
+
|
|
597
|
+
**Scenario:** A dialog contains a form that needs validation, preventing submission with invalid data while keeping the dialog open.
|
|
598
|
+
|
|
599
|
+
**Solution:**
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
interface FormData {
|
|
603
|
+
email: string;
|
|
604
|
+
password: string;
|
|
605
|
+
confirmPassword: string;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
interface FormErrors {
|
|
609
|
+
email?: string;
|
|
610
|
+
password?: string;
|
|
611
|
+
confirmPassword?: string;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const [open, setOpen] = useState(false);
|
|
615
|
+
const [formData, setFormData] = useState<FormData>({
|
|
616
|
+
email: '',
|
|
617
|
+
password: '',
|
|
618
|
+
confirmPassword: '',
|
|
619
|
+
});
|
|
620
|
+
const [errors, setErrors] = useState<FormErrors>({});
|
|
621
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
622
|
+
|
|
623
|
+
const validateForm = (): boolean => {
|
|
624
|
+
const newErrors: FormErrors = {};
|
|
625
|
+
|
|
626
|
+
// Email validation
|
|
627
|
+
if (!formData.email) {
|
|
628
|
+
newErrors.email = 'Email is required';
|
|
629
|
+
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
630
|
+
newErrors.email = 'Invalid email format';
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Password validation
|
|
634
|
+
if (!formData.password) {
|
|
635
|
+
newErrors.password = 'Password is required';
|
|
636
|
+
} else if (formData.password.length < 8) {
|
|
637
|
+
newErrors.password = 'Password must be at least 8 characters';
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Confirm password validation
|
|
641
|
+
if (formData.password !== formData.confirmPassword) {
|
|
642
|
+
newErrors.confirmPassword = 'Passwords do not match';
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
setErrors(newErrors);
|
|
646
|
+
return Object.keys(newErrors).length === 0;
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
650
|
+
e.preventDefault();
|
|
651
|
+
|
|
652
|
+
if (!validateForm()) {
|
|
653
|
+
return; // Keep dialog open, show errors
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
setIsSubmitting(true);
|
|
657
|
+
try {
|
|
658
|
+
await registerUser(formData);
|
|
659
|
+
setOpen(false); // Close dialog on success
|
|
660
|
+
// Reset form
|
|
661
|
+
setFormData({ email: '', password: '', confirmPassword: '' });
|
|
662
|
+
setErrors({});
|
|
663
|
+
} catch (error) {
|
|
664
|
+
setErrors({ email: 'Registration failed. Please try again.' });
|
|
665
|
+
} finally {
|
|
666
|
+
setIsSubmitting(false);
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
<Dialog
|
|
671
|
+
open={open}
|
|
672
|
+
onOpenChange={({ open }) => {
|
|
673
|
+
setOpen(open);
|
|
674
|
+
if (!open) {
|
|
675
|
+
// Reset errors when dialog closes
|
|
676
|
+
setErrors({});
|
|
677
|
+
}
|
|
678
|
+
}}
|
|
679
|
+
title="Create Account"
|
|
680
|
+
size="md"
|
|
681
|
+
>
|
|
682
|
+
<form onSubmit={handleSubmit}>
|
|
683
|
+
<div className={css({ p: 'lg', display: 'flex', flexDirection: 'column', gap: 'md' })}>
|
|
684
|
+
<div>
|
|
685
|
+
<Input
|
|
686
|
+
label="Email"
|
|
687
|
+
type="email"
|
|
688
|
+
value={formData.email}
|
|
689
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
690
|
+
invalid={!!errors.email}
|
|
691
|
+
required
|
|
692
|
+
/>
|
|
693
|
+
{errors.email && (
|
|
694
|
+
<span className={css({ color: 'error.fg', fontSize: 'sm', mt: '1' })}>
|
|
695
|
+
{errors.email}
|
|
696
|
+
</span>
|
|
697
|
+
)}
|
|
698
|
+
</div>
|
|
699
|
+
|
|
700
|
+
<div>
|
|
701
|
+
<Input
|
|
702
|
+
label="Password"
|
|
703
|
+
type="password"
|
|
704
|
+
value={formData.password}
|
|
705
|
+
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
706
|
+
invalid={!!errors.password}
|
|
707
|
+
required
|
|
708
|
+
/>
|
|
709
|
+
{errors.password && (
|
|
710
|
+
<span className={css({ color: 'error.fg', fontSize: 'sm', mt: '1' })}>
|
|
711
|
+
{errors.password}
|
|
712
|
+
</span>
|
|
713
|
+
)}
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
<div>
|
|
717
|
+
<Input
|
|
718
|
+
label="Confirm Password"
|
|
719
|
+
type="password"
|
|
720
|
+
value={formData.confirmPassword}
|
|
721
|
+
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
|
722
|
+
invalid={!!errors.confirmPassword}
|
|
723
|
+
required
|
|
724
|
+
/>
|
|
725
|
+
{errors.confirmPassword && (
|
|
726
|
+
<span className={css({ color: 'error.fg', fontSize: 'sm', mt: '1' })}>
|
|
727
|
+
{errors.confirmPassword}
|
|
728
|
+
</span>
|
|
729
|
+
)}
|
|
730
|
+
</div>
|
|
731
|
+
|
|
732
|
+
<div className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end', mt: 'md' })}>
|
|
733
|
+
<Button
|
|
734
|
+
type="button"
|
|
735
|
+
variant="text"
|
|
736
|
+
onClick={() => setOpen(false)}
|
|
737
|
+
disabled={isSubmitting}
|
|
738
|
+
>
|
|
739
|
+
Cancel
|
|
740
|
+
</Button>
|
|
741
|
+
<Button type="submit" variant="filled" disabled={isSubmitting}>
|
|
742
|
+
{isSubmitting ? 'Creating...' : 'Create Account'}
|
|
743
|
+
</Button>
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
</form>
|
|
747
|
+
</Dialog>
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
**Best practices:**
|
|
751
|
+
|
|
752
|
+
- Validate on submit, not on every keystroke (better UX)
|
|
753
|
+
- Display field-level errors below each input
|
|
754
|
+
- Keep dialog open when validation fails
|
|
755
|
+
- Disable submit button during submission to prevent double-submission
|
|
756
|
+
- Reset form and errors when dialog closes
|
|
757
|
+
- Use ARIA live regions for dynamic error announcements
|
|
758
|
+
|
|
759
|
+
---
|
|
760
|
+
|
|
761
|
+
### Portal Rendering - Custom Container
|
|
762
|
+
|
|
763
|
+
**Scenario:** Dialog needs to render in a specific portal container or custom mount point in the DOM.
|
|
764
|
+
|
|
765
|
+
**Solution:**
|
|
766
|
+
|
|
767
|
+
```typescript
|
|
768
|
+
import { createPortal } from 'react-dom';
|
|
769
|
+
import { useEffect, useState } from 'react';
|
|
770
|
+
|
|
771
|
+
const [open, setOpen] = useState(false);
|
|
772
|
+
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
|
773
|
+
|
|
774
|
+
// Create or find portal container on mount
|
|
775
|
+
useEffect(() => {
|
|
776
|
+
// Check if custom portal exists
|
|
777
|
+
let container = document.getElementById('dialog-portal');
|
|
778
|
+
|
|
779
|
+
if (!container) {
|
|
780
|
+
// Create custom portal container
|
|
781
|
+
container = document.createElement('div');
|
|
782
|
+
container.id = 'dialog-portal';
|
|
783
|
+
container.setAttribute('data-portal', 'dialogs');
|
|
784
|
+
document.body.appendChild(container);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
setPortalContainer(container);
|
|
788
|
+
|
|
789
|
+
// Cleanup on unmount
|
|
790
|
+
return () => {
|
|
791
|
+
// Only remove if we created it
|
|
792
|
+
const existing = document.getElementById('dialog-portal');
|
|
793
|
+
if (existing && existing.childNodes.length === 0) {
|
|
794
|
+
existing.remove();
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
}, []);
|
|
798
|
+
|
|
799
|
+
<>
|
|
800
|
+
<Button onClick={() => setOpen(true)}>Open Dialog</Button>
|
|
801
|
+
|
|
802
|
+
{/* Dialog automatically renders in portal, but you can customize if needed */}
|
|
803
|
+
<Dialog
|
|
804
|
+
open={open}
|
|
805
|
+
onOpenChange={({ open }) => setOpen(open)}
|
|
806
|
+
title="Custom Portal Dialog"
|
|
807
|
+
size="md"
|
|
808
|
+
// Ark UI handles portal rendering automatically
|
|
809
|
+
// For custom portal behavior, use Dialog.Root with custom positioning
|
|
810
|
+
>
|
|
811
|
+
<div className={css({ p: 'lg' })}>
|
|
812
|
+
<p className={css({ mb: 'md' })}>
|
|
813
|
+
This dialog is rendered in a custom portal container.
|
|
814
|
+
</p>
|
|
815
|
+
<p className={css({ fontSize: 'sm', color: 'fg.muted' })}>
|
|
816
|
+
Check the DOM: it's in #dialog-portal, not where the trigger is.
|
|
817
|
+
</p>
|
|
818
|
+
<div className={css({ display: 'flex', justifyContent: 'flex-end', mt: 'lg' })}>
|
|
819
|
+
<Button variant="filled" onClick={() => setOpen(false)}>
|
|
820
|
+
Close
|
|
821
|
+
</Button>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
</Dialog>
|
|
825
|
+
</>
|
|
826
|
+
|
|
827
|
+
{/* For z-index stacking context issues */}
|
|
828
|
+
<div className={css({ position: 'relative', zIndex: 10 })}>
|
|
829
|
+
<Dialog
|
|
830
|
+
open={open}
|
|
831
|
+
onOpenChange={({ open }) => setOpen(open)}
|
|
832
|
+
title="High Z-Index Dialog"
|
|
833
|
+
size="md"
|
|
834
|
+
// Dialog will render with appropriate z-index above other content
|
|
835
|
+
>
|
|
836
|
+
<div className={css({ p: 'lg' })}>
|
|
837
|
+
This dialog appears above elements with lower z-index values.
|
|
838
|
+
</div>
|
|
839
|
+
</Dialog>
|
|
840
|
+
</div>
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
**Best practices:**
|
|
844
|
+
|
|
845
|
+
- Dialog component handles portal rendering automatically via Ark UI
|
|
846
|
+
- Custom portal containers are rarely needed unless integrating with specific frameworks
|
|
847
|
+
- Ensure portal containers are at root level for proper stacking context
|
|
848
|
+
- Clean up dynamically created portal containers on unmount
|
|
849
|
+
- Use CSS z-index values from design system for consistent layering
|
|
850
|
+
- Test dialog positioning in different stacking contexts
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
268
854
|
## DO NOT
|
|
269
855
|
|
|
270
856
|
```typescript
|
|
@@ -354,28 +940,29 @@ The Dialog component follows WCAG 2.1 Level AA standards:
|
|
|
354
940
|
|
|
355
941
|
## Size Selection Guide
|
|
356
942
|
|
|
357
|
-
| Scenario
|
|
358
|
-
|
|
359
|
-
| Simple confirmation
|
|
360
|
-
| Alerts
|
|
361
|
-
| Forms (2-3 fields)
|
|
362
|
-
| Settings/Preferences | `lg`
|
|
363
|
-
| Mobile editor/viewer | `fullscreen`
|
|
364
|
-
| Multi-step wizard
|
|
943
|
+
| Scenario | Recommended Size | Reasoning |
|
|
944
|
+
| -------------------- | -------------------- | ------------------------------- |
|
|
945
|
+
| Simple confirmation | `sm` | Minimal content, quick decision |
|
|
946
|
+
| Alerts | `sm` | Brief message, single action |
|
|
947
|
+
| Forms (2-3 fields) | `md` | Standard forms, most common |
|
|
948
|
+
| Settings/Preferences | `lg` | Multiple sections, complex UI |
|
|
949
|
+
| Mobile editor/viewer | `fullscreen` | Maximize screen space |
|
|
950
|
+
| Multi-step wizard | `lg` or `fullscreen` | Complex flow needs space |
|
|
365
951
|
|
|
366
952
|
## State Behaviors
|
|
367
953
|
|
|
368
|
-
| State
|
|
369
|
-
|
|
370
|
-
| **Opening**
|
|
371
|
-
| **Open**
|
|
372
|
-
| **Closing**
|
|
373
|
-
| **Backdrop Click** | Closes dialog
|
|
374
|
-
| **Escape Key**
|
|
954
|
+
| State | Visual Change | Behavior |
|
|
955
|
+
| ------------------ | -------------------------- | ---------------------------------------------- |
|
|
956
|
+
| **Opening** | Fade in + scale animation | Backdrop fades, content scales up |
|
|
957
|
+
| **Open** | Fully visible | Modal state, focus trapped |
|
|
958
|
+
| **Closing** | Fade out + scale animation | Backdrop fades, content scales down |
|
|
959
|
+
| **Backdrop Click** | Closes dialog | Click outside closes (default Ark UI behavior) |
|
|
960
|
+
| **Escape Key** | Closes dialog | Keyboard shortcut to close |
|
|
375
961
|
|
|
376
962
|
## Backdrop Behavior
|
|
377
963
|
|
|
378
964
|
The backdrop (scrim) behind the dialog:
|
|
965
|
+
|
|
379
966
|
- Uses `scrim` color token (#000000)
|
|
380
967
|
- 40% opacity
|
|
381
968
|
- Blocks all background interactions
|
|
@@ -384,6 +971,7 @@ The backdrop (scrim) behind the dialog:
|
|
|
384
971
|
## Focus Management
|
|
385
972
|
|
|
386
973
|
The Dialog component automatically:
|
|
974
|
+
|
|
387
975
|
1. **Traps focus** within the dialog when open
|
|
388
976
|
2. **Focuses first focusable element** when opened (typically close button or first input)
|
|
389
977
|
3. **Restores focus** to the trigger element when closed
|