@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.
Files changed (36) hide show
  1. package/README.md +12 -4
  2. package/dist/styles.css +5126 -0
  3. package/guidelines/Guidelines.md +67 -123
  4. package/guidelines/components/accordion.md +93 -0
  5. package/guidelines/components/avatar.md +70 -0
  6. package/guidelines/components/badge.md +61 -0
  7. package/guidelines/components/button.md +75 -40
  8. package/guidelines/components/card.md +84 -25
  9. package/guidelines/components/checkbox.md +88 -0
  10. package/guidelines/components/dialog.md +619 -31
  11. package/guidelines/components/drawer.md +655 -0
  12. package/guidelines/components/heading.md +71 -0
  13. package/guidelines/components/icon-button.md +92 -37
  14. package/guidelines/components/input-addon.md +685 -0
  15. package/guidelines/components/input-group.md +830 -0
  16. package/guidelines/components/input.md +92 -37
  17. package/guidelines/components/popover.md +71 -0
  18. package/guidelines/components/progress.md +63 -0
  19. package/guidelines/components/radio-group.md +95 -0
  20. package/guidelines/components/select.md +507 -0
  21. package/guidelines/components/skeleton.md +76 -0
  22. package/guidelines/components/slider.md +911 -0
  23. package/guidelines/components/spinner.md +783 -0
  24. package/guidelines/components/switch.md +105 -38
  25. package/guidelines/components/tabs.md +654 -0
  26. package/guidelines/components/textarea.md +70 -0
  27. package/guidelines/components/toast.md +77 -0
  28. package/guidelines/components/tooltip.md +80 -0
  29. package/guidelines/design-tokens/colors.md +309 -72
  30. package/guidelines/design-tokens/elevation.md +615 -45
  31. package/guidelines/design-tokens/spacing.md +654 -74
  32. package/guidelines/design-tokens/typography.md +432 -50
  33. package/guidelines/overview-components.md +9 -5
  34. package/guidelines/overview-imports.md +314 -0
  35. package/guidelines/overview-patterns.md +3852 -0
  36. 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 | 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 |
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 | 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 |
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 | 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 |
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 | 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 |
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