@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,97 @@
|
|
|
2
2
|
|
|
3
3
|
**Purpose:** A panel that slides in from the edge of the screen, used for navigation, forms, or additional content without leaving the current context. Built on Ark UI's Dialog primitive with specialized styling for edge-anchored panels.
|
|
4
4
|
|
|
5
|
+
## When to Use This Component
|
|
6
|
+
|
|
7
|
+
Use Drawer when you need to **display secondary content, navigation, or forms that slide in from the screen edge** without interrupting the user's context.
|
|
8
|
+
|
|
9
|
+
### Decision Tree
|
|
10
|
+
|
|
11
|
+
| Scenario | Use Drawer? | Alternative | Reasoning |
|
|
12
|
+
| ------------------------------------ | ----------- | ------------------ | ------------------------------------------------- |
|
|
13
|
+
| Navigation menu (mobile) | ✅ Yes | - | Drawer slides from side, perfect for mobile menus |
|
|
14
|
+
| Displaying filters or settings panel | ✅ Yes | - | Keeps main content visible while showing options |
|
|
15
|
+
| Shopping cart or preview panel | ✅ Yes | - | Non-modal context, easy to dismiss |
|
|
16
|
+
| Critical confirmations or alerts | ❌ No | Dialog | Dialog is centered and demands more attention |
|
|
17
|
+
| Small contextual information | ❌ No | Popover or Tooltip | Drawer is too heavy for brief hints |
|
|
18
|
+
| Multi-step form as primary content | ❌ No | Full page | Complex forms deserve dedicated space |
|
|
19
|
+
|
|
20
|
+
### Component Comparison
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// ✅ Drawer - Navigation menu from side
|
|
24
|
+
<Drawer.Root placement="start" size="sm">
|
|
25
|
+
<Drawer.Trigger asChild>
|
|
26
|
+
<Button leftIcon={<MenuIcon />}>Menu</Button>
|
|
27
|
+
</Drawer.Trigger>
|
|
28
|
+
<Drawer.Backdrop />
|
|
29
|
+
<Drawer.Positioner>
|
|
30
|
+
<Drawer.Content>
|
|
31
|
+
<Drawer.Header>
|
|
32
|
+
<Drawer.Title>Navigation</Drawer.Title>
|
|
33
|
+
<Drawer.CloseTrigger asChild>
|
|
34
|
+
<IconButton aria-label="Close"><XIcon /></IconButton>
|
|
35
|
+
</Drawer.CloseTrigger>
|
|
36
|
+
</Drawer.Header>
|
|
37
|
+
<Drawer.Body>
|
|
38
|
+
<nav>
|
|
39
|
+
<a href="/home">Home</a>
|
|
40
|
+
<a href="/about">About</a>
|
|
41
|
+
</nav>
|
|
42
|
+
</Drawer.Body>
|
|
43
|
+
</Drawer.Content>
|
|
44
|
+
</Drawer.Positioner>
|
|
45
|
+
</Drawer.Root>
|
|
46
|
+
|
|
47
|
+
// ❌ Don't use Drawer for critical alerts - Use Dialog
|
|
48
|
+
<Drawer.Root placement="bottom">
|
|
49
|
+
<Drawer.Content>
|
|
50
|
+
<Drawer.Title>Delete Account?</Drawer.Title>
|
|
51
|
+
<Drawer.Body>This action cannot be undone.</Drawer.Body>
|
|
52
|
+
{/* Critical actions need centered Dialog */}
|
|
53
|
+
</Drawer.Content>
|
|
54
|
+
</Drawer.Root>
|
|
55
|
+
|
|
56
|
+
// ✅ Better: Use Dialog for critical confirmations
|
|
57
|
+
<Dialog.Root>
|
|
58
|
+
<Dialog.Backdrop />
|
|
59
|
+
<Dialog.Positioner>
|
|
60
|
+
<Dialog.Content>
|
|
61
|
+
<Dialog.Title>Delete Account?</Dialog.Title>
|
|
62
|
+
<Dialog.Description>
|
|
63
|
+
This action cannot be undone.
|
|
64
|
+
</Dialog.Description>
|
|
65
|
+
<Dialog.Footer>
|
|
66
|
+
<Button variant="outlined">Cancel</Button>
|
|
67
|
+
<Button colorPalette="error">Delete</Button>
|
|
68
|
+
</Dialog.Footer>
|
|
69
|
+
</Dialog.Content>
|
|
70
|
+
</Dialog.Positioner>
|
|
71
|
+
</Dialog.Root>
|
|
72
|
+
|
|
73
|
+
// ❌ Don't use Drawer for small hints - Use Popover
|
|
74
|
+
<Drawer.Root size="xs">
|
|
75
|
+
<Drawer.Content>
|
|
76
|
+
<Drawer.Body>
|
|
77
|
+
Click here for more info
|
|
78
|
+
</Drawer.Body>
|
|
79
|
+
</Drawer.Content>
|
|
80
|
+
</Drawer.Root>
|
|
81
|
+
|
|
82
|
+
// ✅ Better: Use Popover for contextual info
|
|
83
|
+
<Popover.Root>
|
|
84
|
+
<Popover.Trigger asChild>
|
|
85
|
+
<Button>Info</Button>
|
|
86
|
+
</Popover.Trigger>
|
|
87
|
+
<Popover.Positioner>
|
|
88
|
+
<Popover.Content>
|
|
89
|
+
<Popover.Title>Quick Info</Popover.Title>
|
|
90
|
+
<Popover.Description>Click here for more info</Popover.Description>
|
|
91
|
+
</Popover.Content>
|
|
92
|
+
</Popover.Positioner>
|
|
93
|
+
</Popover.Root>
|
|
94
|
+
```
|
|
95
|
+
|
|
5
96
|
## Import
|
|
6
97
|
|
|
7
98
|
```typescript
|
|
@@ -659,6 +750,570 @@ function ResponsiveDrawer() {
|
|
|
659
750
|
}
|
|
660
751
|
```
|
|
661
752
|
|
|
753
|
+
## Edge Cases
|
|
754
|
+
|
|
755
|
+
This section covers common edge cases and how to handle them properly.
|
|
756
|
+
|
|
757
|
+
### Stacked Drawers - Multiple Drawers Open
|
|
758
|
+
|
|
759
|
+
**Scenario:** Multiple drawers need to be open simultaneously, such as a navigation drawer with an overlay drawer for details.
|
|
760
|
+
|
|
761
|
+
**Solution:**
|
|
762
|
+
|
|
763
|
+
```typescript
|
|
764
|
+
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
|
765
|
+
const [detailsDrawerOpen, setDetailsDrawerOpen] = useState(false);
|
|
766
|
+
|
|
767
|
+
// Track z-index levels for proper stacking
|
|
768
|
+
const navDrawerZIndex = 1000;
|
|
769
|
+
const detailsDrawerZIndex = 1100;
|
|
770
|
+
|
|
771
|
+
<>
|
|
772
|
+
{/* Primary navigation drawer from left */}
|
|
773
|
+
<Drawer.Root
|
|
774
|
+
placement="start"
|
|
775
|
+
size="sm"
|
|
776
|
+
open={navDrawerOpen}
|
|
777
|
+
onOpenChange={(details) => setNavDrawerOpen(details.open)}
|
|
778
|
+
>
|
|
779
|
+
<Drawer.Trigger asChild>
|
|
780
|
+
<Button leftIcon={<MenuIcon />}>Menu</Button>
|
|
781
|
+
</Drawer.Trigger>
|
|
782
|
+
|
|
783
|
+
<Drawer.Backdrop style={{ zIndex: navDrawerZIndex }} />
|
|
784
|
+
|
|
785
|
+
<Drawer.Positioner style={{ zIndex: navDrawerZIndex + 1 }}>
|
|
786
|
+
<Drawer.Content>
|
|
787
|
+
<Drawer.Header>
|
|
788
|
+
<Drawer.Title>Navigation</Drawer.Title>
|
|
789
|
+
<Drawer.CloseTrigger asChild>
|
|
790
|
+
<IconButton aria-label="Close menu" variant="text" size="sm">
|
|
791
|
+
<XIcon />
|
|
792
|
+
</IconButton>
|
|
793
|
+
</Drawer.CloseTrigger>
|
|
794
|
+
</Drawer.Header>
|
|
795
|
+
|
|
796
|
+
<Drawer.Body>
|
|
797
|
+
<nav className={css({ display: 'flex', flexDirection: 'column', gap: '2' })}>
|
|
798
|
+
<a href="/">Home</a>
|
|
799
|
+
<a href="/about">About</a>
|
|
800
|
+
<button
|
|
801
|
+
onClick={() => setDetailsDrawerOpen(true)}
|
|
802
|
+
className={css({ textAlign: 'left', p: '2' })}
|
|
803
|
+
>
|
|
804
|
+
View Details
|
|
805
|
+
</button>
|
|
806
|
+
</nav>
|
|
807
|
+
</Drawer.Body>
|
|
808
|
+
</Drawer.Content>
|
|
809
|
+
</Drawer.Positioner>
|
|
810
|
+
</Drawer.Root>
|
|
811
|
+
|
|
812
|
+
{/* Secondary details drawer from right - higher z-index */}
|
|
813
|
+
<Drawer.Root
|
|
814
|
+
placement="end"
|
|
815
|
+
size="md"
|
|
816
|
+
open={detailsDrawerOpen}
|
|
817
|
+
onOpenChange={(details) => setDetailsDrawerOpen(details.open)}
|
|
818
|
+
>
|
|
819
|
+
<Drawer.Backdrop style={{ zIndex: detailsDrawerZIndex }} />
|
|
820
|
+
|
|
821
|
+
<Drawer.Positioner style={{ zIndex: detailsDrawerZIndex + 1 }}>
|
|
822
|
+
<Drawer.Content>
|
|
823
|
+
<Drawer.Header>
|
|
824
|
+
<Drawer.Title>Details</Drawer.Title>
|
|
825
|
+
<Drawer.CloseTrigger asChild>
|
|
826
|
+
<IconButton aria-label="Close details" variant="text" size="sm">
|
|
827
|
+
<XIcon />
|
|
828
|
+
</IconButton>
|
|
829
|
+
</Drawer.CloseTrigger>
|
|
830
|
+
</Drawer.Header>
|
|
831
|
+
|
|
832
|
+
<Drawer.Body>
|
|
833
|
+
<div className={css({ p: '4' })}>
|
|
834
|
+
<p>This drawer appears on top of the navigation drawer.</p>
|
|
835
|
+
<p className={css({ mt: '2', fontSize: 'sm', color: 'fg.muted' })}>
|
|
836
|
+
Both drawers remain independently interactive.
|
|
837
|
+
</p>
|
|
838
|
+
</div>
|
|
839
|
+
</Drawer.Body>
|
|
840
|
+
|
|
841
|
+
<Drawer.Footer>
|
|
842
|
+
<Button variant="outlined" onClick={() => setDetailsDrawerOpen(false)}>
|
|
843
|
+
Close
|
|
844
|
+
</Button>
|
|
845
|
+
</Drawer.Footer>
|
|
846
|
+
</Drawer.Content>
|
|
847
|
+
</Drawer.Positioner>
|
|
848
|
+
</Drawer.Root>
|
|
849
|
+
</>
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
**Best practices:**
|
|
853
|
+
|
|
854
|
+
- Limit stacked drawers to two maximum to avoid confusion
|
|
855
|
+
- Use different placements for stacked drawers (e.g., start + end)
|
|
856
|
+
- Ensure proper z-index stacking so upper drawers overlay lower ones
|
|
857
|
+
- Make each drawer independently closable
|
|
858
|
+
- Consider closing lower drawers when opening upper ones for simplicity
|
|
859
|
+
|
|
860
|
+
---
|
|
861
|
+
|
|
862
|
+
### Mobile Considerations - Full-Screen Behavior
|
|
863
|
+
|
|
864
|
+
**Scenario:** Drawers should adapt to mobile screens, potentially becoming full-screen to maximize usable space.
|
|
865
|
+
|
|
866
|
+
**Solution:**
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
870
|
+
|
|
871
|
+
const [open, setOpen] = useState(false);
|
|
872
|
+
const isMobile = useMediaQuery('(max-width: 768px)');
|
|
873
|
+
|
|
874
|
+
<Drawer.Root
|
|
875
|
+
placement={isMobile ? 'bottom' : 'end'}
|
|
876
|
+
size={isMobile ? 'full' : 'md'}
|
|
877
|
+
open={open}
|
|
878
|
+
onOpenChange={(details) => setOpen(details.open)}
|
|
879
|
+
>
|
|
880
|
+
<Drawer.Trigger asChild>
|
|
881
|
+
<Button>Open Filters</Button>
|
|
882
|
+
</Drawer.Trigger>
|
|
883
|
+
|
|
884
|
+
<Drawer.Backdrop />
|
|
885
|
+
|
|
886
|
+
<Drawer.Positioner>
|
|
887
|
+
<Drawer.Content
|
|
888
|
+
className={css({
|
|
889
|
+
// On mobile, add safe area padding for notched devices
|
|
890
|
+
paddingBottom: isMobile ? 'env(safe-area-inset-bottom)' : undefined,
|
|
891
|
+
})}
|
|
892
|
+
>
|
|
893
|
+
<Drawer.Header>
|
|
894
|
+
<Drawer.Title>Filter Options</Drawer.Title>
|
|
895
|
+
<Drawer.CloseTrigger asChild>
|
|
896
|
+
<IconButton aria-label="Close filters" variant="text" size="sm">
|
|
897
|
+
<XIcon />
|
|
898
|
+
</IconButton>
|
|
899
|
+
</Drawer.CloseTrigger>
|
|
900
|
+
</Drawer.Header>
|
|
901
|
+
|
|
902
|
+
<Drawer.Body>
|
|
903
|
+
{/* Filter options */}
|
|
904
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: '4' })}>
|
|
905
|
+
<div>
|
|
906
|
+
<label className={css({ display: 'block', mb: '2' })}>Price Range</label>
|
|
907
|
+
<input type="range" min="0" max="1000" />
|
|
908
|
+
</div>
|
|
909
|
+
<div>
|
|
910
|
+
<label className={css({ display: 'block', mb: '2' })}>Category</label>
|
|
911
|
+
<Select.Root items={['All', 'Electronics', 'Clothing']}>
|
|
912
|
+
<Select.Control>
|
|
913
|
+
<Select.Trigger>
|
|
914
|
+
<Select.ValueText placeholder="Select category" />
|
|
915
|
+
</Select.Trigger>
|
|
916
|
+
</Select.Control>
|
|
917
|
+
</Select.Root>
|
|
918
|
+
</div>
|
|
919
|
+
</div>
|
|
920
|
+
</Drawer.Body>
|
|
921
|
+
|
|
922
|
+
<Drawer.Footer
|
|
923
|
+
className={css({
|
|
924
|
+
// Stick footer to bottom on mobile
|
|
925
|
+
position: isMobile ? 'sticky' : 'relative',
|
|
926
|
+
bottom: 0,
|
|
927
|
+
bg: 'bg.canvas',
|
|
928
|
+
borderTop: '1px solid',
|
|
929
|
+
borderColor: 'gray.4',
|
|
930
|
+
})}
|
|
931
|
+
>
|
|
932
|
+
<Button variant="outlined" onClick={() => setOpen(false)}>
|
|
933
|
+
Clear
|
|
934
|
+
</Button>
|
|
935
|
+
<Button variant="filled">Apply Filters</Button>
|
|
936
|
+
</Drawer.Footer>
|
|
937
|
+
</Drawer.Content>
|
|
938
|
+
</Drawer.Positioner>
|
|
939
|
+
</Drawer.Root>
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
**Best practices:**
|
|
943
|
+
|
|
944
|
+
- Use `placement="bottom"` and `size="full"` for mobile screens
|
|
945
|
+
- Add safe area insets for devices with notches
|
|
946
|
+
- Make close buttons large and accessible on touch devices (min 44x44px)
|
|
947
|
+
- Stick important actions (footer) to viewport bottom
|
|
948
|
+
- Test gesture interactions (swipe to close) on mobile devices
|
|
949
|
+
|
|
950
|
+
---
|
|
951
|
+
|
|
952
|
+
### Nested Scrolling - Content Overflow
|
|
953
|
+
|
|
954
|
+
**Scenario:** Drawer content is taller than the viewport, requiring scrollable areas while keeping header and footer fixed.
|
|
955
|
+
|
|
956
|
+
**Solution:**
|
|
957
|
+
|
|
958
|
+
```typescript
|
|
959
|
+
const [open, setOpen] = useState(false);
|
|
960
|
+
|
|
961
|
+
// Generate long content for demo
|
|
962
|
+
const longContent = Array.from({ length: 50 }, (_, i) => `Item ${i + 1}`);
|
|
963
|
+
|
|
964
|
+
<Drawer.Root placement="end" size="md" open={open} onOpenChange={(details) => setOpen(details.open)}>
|
|
965
|
+
<Drawer.Trigger asChild>
|
|
966
|
+
<Button>View Long List</Button>
|
|
967
|
+
</Drawer.Trigger>
|
|
968
|
+
|
|
969
|
+
<Drawer.Backdrop />
|
|
970
|
+
|
|
971
|
+
<Drawer.Positioner>
|
|
972
|
+
<Drawer.Content
|
|
973
|
+
className={css({
|
|
974
|
+
display: 'flex',
|
|
975
|
+
flexDirection: 'column',
|
|
976
|
+
height: '100dvh', // Use dvh for mobile viewport height
|
|
977
|
+
maxHeight: '100dvh',
|
|
978
|
+
})}
|
|
979
|
+
>
|
|
980
|
+
{/* Fixed header */}
|
|
981
|
+
<Drawer.Header
|
|
982
|
+
className={css({
|
|
983
|
+
flexShrink: 0, // Prevent shrinking
|
|
984
|
+
borderBottom: '1px solid',
|
|
985
|
+
borderColor: 'gray.4',
|
|
986
|
+
position: 'sticky',
|
|
987
|
+
top: 0,
|
|
988
|
+
bg: 'bg.canvas',
|
|
989
|
+
zIndex: 1,
|
|
990
|
+
})}
|
|
991
|
+
>
|
|
992
|
+
<Drawer.Title>Scrollable Content</Drawer.Title>
|
|
993
|
+
<Drawer.Description>
|
|
994
|
+
This drawer has a long list that scrolls independently
|
|
995
|
+
</Drawer.Description>
|
|
996
|
+
<Drawer.CloseTrigger asChild>
|
|
997
|
+
<IconButton aria-label="Close" variant="text" size="sm">
|
|
998
|
+
<XIcon />
|
|
999
|
+
</IconButton>
|
|
1000
|
+
</Drawer.CloseTrigger>
|
|
1001
|
+
</Drawer.Header>
|
|
1002
|
+
|
|
1003
|
+
{/* Scrollable body */}
|
|
1004
|
+
<Drawer.Body
|
|
1005
|
+
className={css({
|
|
1006
|
+
flex: 1, // Take remaining space
|
|
1007
|
+
overflowY: 'auto', // Enable scrolling
|
|
1008
|
+
overflowX: 'hidden',
|
|
1009
|
+
WebkitOverflowScrolling: 'touch', // Smooth scrolling on iOS
|
|
1010
|
+
})}
|
|
1011
|
+
>
|
|
1012
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2', p: '4' })}>
|
|
1013
|
+
{longContent.map((item) => (
|
|
1014
|
+
<div
|
|
1015
|
+
key={item}
|
|
1016
|
+
className={css({
|
|
1017
|
+
p: '3',
|
|
1018
|
+
bg: 'gray.a2',
|
|
1019
|
+
borderRadius: 'md',
|
|
1020
|
+
})}
|
|
1021
|
+
>
|
|
1022
|
+
{item}
|
|
1023
|
+
</div>
|
|
1024
|
+
))}
|
|
1025
|
+
</div>
|
|
1026
|
+
</Drawer.Body>
|
|
1027
|
+
|
|
1028
|
+
{/* Fixed footer */}
|
|
1029
|
+
<Drawer.Footer
|
|
1030
|
+
className={css({
|
|
1031
|
+
flexShrink: 0, // Prevent shrinking
|
|
1032
|
+
borderTop: '1px solid',
|
|
1033
|
+
borderColor: 'gray.4',
|
|
1034
|
+
position: 'sticky',
|
|
1035
|
+
bottom: 0,
|
|
1036
|
+
bg: 'bg.canvas',
|
|
1037
|
+
})}
|
|
1038
|
+
>
|
|
1039
|
+
<Button variant="outlined" onClick={() => setOpen(false)}>
|
|
1040
|
+
Cancel
|
|
1041
|
+
</Button>
|
|
1042
|
+
<Button variant="filled">Confirm</Button>
|
|
1043
|
+
</Drawer.Footer>
|
|
1044
|
+
</Drawer.Content>
|
|
1045
|
+
</Drawer.Positioner>
|
|
1046
|
+
</Drawer.Root>
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
**Best practices:**
|
|
1050
|
+
|
|
1051
|
+
- Use flexbox layout with `flex: 1` on body for proper scrolling
|
|
1052
|
+
- Make header and footer sticky with explicit backgrounds
|
|
1053
|
+
- Use `100dvh` instead of `100vh` for accurate mobile viewport height
|
|
1054
|
+
- Enable smooth scrolling on iOS with `-webkit-overflow-scrolling`
|
|
1055
|
+
- Test scrolling performance with large lists
|
|
1056
|
+
- Consider virtual scrolling for very long lists
|
|
1057
|
+
|
|
1058
|
+
---
|
|
1059
|
+
|
|
1060
|
+
### Backdrop Click - Preventing Close
|
|
1061
|
+
|
|
1062
|
+
**Scenario:** Prevent users from accidentally closing the drawer by clicking outside, requiring explicit close action.
|
|
1063
|
+
|
|
1064
|
+
**Solution:**
|
|
1065
|
+
|
|
1066
|
+
```typescript
|
|
1067
|
+
const [open, setOpen] = useState(false);
|
|
1068
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
1069
|
+
const [showWarning, setShowWarning] = useState(false);
|
|
1070
|
+
|
|
1071
|
+
const handleInteractOutside = (event: Event) => {
|
|
1072
|
+
if (hasUnsavedChanges) {
|
|
1073
|
+
event.preventDefault(); // Prevent drawer from closing
|
|
1074
|
+
setShowWarning(true);
|
|
1075
|
+
}
|
|
1076
|
+
// If no unsaved changes, allow default behavior (drawer closes)
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
const confirmClose = () => {
|
|
1080
|
+
setHasUnsavedChanges(false);
|
|
1081
|
+
setShowWarning(false);
|
|
1082
|
+
setOpen(false);
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
<>
|
|
1086
|
+
<Drawer.Root
|
|
1087
|
+
open={open}
|
|
1088
|
+
onOpenChange={(details) => setOpen(details.open)}
|
|
1089
|
+
// closeOnInteractOutside={!hasUnsavedChanges} // Simple approach
|
|
1090
|
+
onInteractOutside={handleInteractOutside} // Advanced approach with warning
|
|
1091
|
+
closeOnEscapeKeyDown={!hasUnsavedChanges}
|
|
1092
|
+
>
|
|
1093
|
+
<Drawer.Trigger asChild>
|
|
1094
|
+
<Button>Edit Document</Button>
|
|
1095
|
+
</Drawer.Trigger>
|
|
1096
|
+
|
|
1097
|
+
<Drawer.Backdrop />
|
|
1098
|
+
|
|
1099
|
+
<Drawer.Positioner>
|
|
1100
|
+
<Drawer.Content>
|
|
1101
|
+
<Drawer.Header>
|
|
1102
|
+
<Drawer.Title>
|
|
1103
|
+
Edit Document
|
|
1104
|
+
{hasUnsavedChanges && (
|
|
1105
|
+
<span className={css({ ml: '2', fontSize: 'sm', color: 'warning.fg' })}>
|
|
1106
|
+
• Unsaved changes
|
|
1107
|
+
</span>
|
|
1108
|
+
)}
|
|
1109
|
+
</Drawer.Title>
|
|
1110
|
+
<Drawer.CloseTrigger asChild>
|
|
1111
|
+
<IconButton
|
|
1112
|
+
aria-label="Close"
|
|
1113
|
+
variant="text"
|
|
1114
|
+
size="sm"
|
|
1115
|
+
onClick={(e) => {
|
|
1116
|
+
if (hasUnsavedChanges) {
|
|
1117
|
+
e.preventDefault();
|
|
1118
|
+
setShowWarning(true);
|
|
1119
|
+
}
|
|
1120
|
+
}}
|
|
1121
|
+
>
|
|
1122
|
+
<XIcon />
|
|
1123
|
+
</IconButton>
|
|
1124
|
+
</Drawer.CloseTrigger>
|
|
1125
|
+
</Drawer.Header>
|
|
1126
|
+
|
|
1127
|
+
<Drawer.Body>
|
|
1128
|
+
<Textarea
|
|
1129
|
+
label="Content"
|
|
1130
|
+
rows={10}
|
|
1131
|
+
onChange={() => setHasUnsavedChanges(true)}
|
|
1132
|
+
placeholder="Start typing to trigger unsaved changes..."
|
|
1133
|
+
/>
|
|
1134
|
+
</Drawer.Body>
|
|
1135
|
+
|
|
1136
|
+
<Drawer.Footer>
|
|
1137
|
+
<Button
|
|
1138
|
+
variant="outlined"
|
|
1139
|
+
onClick={() => {
|
|
1140
|
+
if (hasUnsavedChanges) {
|
|
1141
|
+
setShowWarning(true);
|
|
1142
|
+
} else {
|
|
1143
|
+
setOpen(false);
|
|
1144
|
+
}
|
|
1145
|
+
}}
|
|
1146
|
+
>
|
|
1147
|
+
Cancel
|
|
1148
|
+
</Button>
|
|
1149
|
+
<Button
|
|
1150
|
+
variant="filled"
|
|
1151
|
+
onClick={() => {
|
|
1152
|
+
// Save logic
|
|
1153
|
+
setHasUnsavedChanges(false);
|
|
1154
|
+
setOpen(false);
|
|
1155
|
+
}}
|
|
1156
|
+
>
|
|
1157
|
+
Save
|
|
1158
|
+
</Button>
|
|
1159
|
+
</Drawer.Footer>
|
|
1160
|
+
</Drawer.Content>
|
|
1161
|
+
</Drawer.Positioner>
|
|
1162
|
+
</Drawer.Root>
|
|
1163
|
+
|
|
1164
|
+
{/* Warning dialog */}
|
|
1165
|
+
{showWarning && (
|
|
1166
|
+
<Dialog
|
|
1167
|
+
open={showWarning}
|
|
1168
|
+
onOpenChange={({ open }) => setShowWarning(open)}
|
|
1169
|
+
title="Unsaved Changes"
|
|
1170
|
+
size="sm"
|
|
1171
|
+
>
|
|
1172
|
+
<div className={css({ p: 'lg' })}>
|
|
1173
|
+
<p className={css({ mb: 'lg' })}>
|
|
1174
|
+
You have unsaved changes. Are you sure you want to close without saving?
|
|
1175
|
+
</p>
|
|
1176
|
+
<div className={css({ display: 'flex', gap: 'sm', justifyContent: 'flex-end' })}>
|
|
1177
|
+
<Button variant="outlined" onClick={() => setShowWarning(false)}>
|
|
1178
|
+
Keep Editing
|
|
1179
|
+
</Button>
|
|
1180
|
+
<Button variant="filled" colorPalette="error" onClick={confirmClose}>
|
|
1181
|
+
Discard Changes
|
|
1182
|
+
</Button>
|
|
1183
|
+
</div>
|
|
1184
|
+
</div>
|
|
1185
|
+
</Dialog>
|
|
1186
|
+
)}
|
|
1187
|
+
</>
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
**Best practices:**
|
|
1191
|
+
|
|
1192
|
+
- Use `closeOnInteractOutside={false}` for critical forms
|
|
1193
|
+
- Show clear visual indicators for unsaved changes
|
|
1194
|
+
- Provide explicit save/discard options
|
|
1195
|
+
- Use confirmation dialogs for destructive actions
|
|
1196
|
+
- Allow Escape key close only when safe
|
|
1197
|
+
- Communicate blocked close actions with visual feedback
|
|
1198
|
+
|
|
1199
|
+
---
|
|
1200
|
+
|
|
1201
|
+
### Animation Interruption - Opening/Closing During Animation
|
|
1202
|
+
|
|
1203
|
+
**Scenario:** User rapidly toggles drawer open/close, causing animation interruptions and potential state issues.
|
|
1204
|
+
|
|
1205
|
+
**Solution:**
|
|
1206
|
+
|
|
1207
|
+
```typescript
|
|
1208
|
+
const [open, setOpen] = useState(false);
|
|
1209
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
1210
|
+
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
1211
|
+
|
|
1212
|
+
const handleOpenChange = (details: { open: boolean }) => {
|
|
1213
|
+
// Clear any pending animation timeout
|
|
1214
|
+
if (animationTimeoutRef.current) {
|
|
1215
|
+
clearTimeout(animationTimeoutRef.current);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Set animating state
|
|
1219
|
+
setIsAnimating(true);
|
|
1220
|
+
|
|
1221
|
+
// Update open state
|
|
1222
|
+
setOpen(details.open);
|
|
1223
|
+
|
|
1224
|
+
// Animation durations from design system
|
|
1225
|
+
// Opening: slowest (500ms), Closing: normal (250ms)
|
|
1226
|
+
const animationDuration = details.open ? 500 : 250;
|
|
1227
|
+
|
|
1228
|
+
// Clear animating state after animation completes
|
|
1229
|
+
animationTimeoutRef.current = setTimeout(() => {
|
|
1230
|
+
setIsAnimating(false);
|
|
1231
|
+
}, animationDuration);
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
// Cleanup on unmount
|
|
1235
|
+
useEffect(() => {
|
|
1236
|
+
return () => {
|
|
1237
|
+
if (animationTimeoutRef.current) {
|
|
1238
|
+
clearTimeout(animationTimeoutRef.current);
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
}, []);
|
|
1242
|
+
|
|
1243
|
+
<div>
|
|
1244
|
+
<div className={css({ mb: '4' })}>
|
|
1245
|
+
<Button onClick={() => handleOpenChange({ open: true })} disabled={isAnimating}>
|
|
1246
|
+
Open Drawer
|
|
1247
|
+
</Button>
|
|
1248
|
+
<span className={css({ ml: '2', fontSize: 'sm', color: 'fg.muted' })}>
|
|
1249
|
+
{isAnimating ? 'Animating...' : 'Ready'}
|
|
1250
|
+
</span>
|
|
1251
|
+
</div>
|
|
1252
|
+
|
|
1253
|
+
<Drawer.Root
|
|
1254
|
+
open={open}
|
|
1255
|
+
onOpenChange={handleOpenChange}
|
|
1256
|
+
// Disable interactions during animation
|
|
1257
|
+
modal={!isAnimating}
|
|
1258
|
+
>
|
|
1259
|
+
<Drawer.Backdrop
|
|
1260
|
+
className={css({
|
|
1261
|
+
// Ensure backdrop respects animation state
|
|
1262
|
+
pointerEvents: isAnimating ? 'none' : 'auto',
|
|
1263
|
+
})}
|
|
1264
|
+
/>
|
|
1265
|
+
|
|
1266
|
+
<Drawer.Positioner>
|
|
1267
|
+
<Drawer.Content
|
|
1268
|
+
className={css({
|
|
1269
|
+
// Prevent content interaction during animation
|
|
1270
|
+
pointerEvents: isAnimating ? 'none' : 'auto',
|
|
1271
|
+
})}
|
|
1272
|
+
>
|
|
1273
|
+
<Drawer.Header>
|
|
1274
|
+
<Drawer.Title>Animated Drawer</Drawer.Title>
|
|
1275
|
+
<Drawer.CloseTrigger asChild>
|
|
1276
|
+
<IconButton
|
|
1277
|
+
aria-label="Close"
|
|
1278
|
+
variant="text"
|
|
1279
|
+
size="sm"
|
|
1280
|
+
disabled={isAnimating}
|
|
1281
|
+
>
|
|
1282
|
+
<XIcon />
|
|
1283
|
+
</IconButton>
|
|
1284
|
+
</Drawer.CloseTrigger>
|
|
1285
|
+
</Drawer.Header>
|
|
1286
|
+
|
|
1287
|
+
<Drawer.Body>
|
|
1288
|
+
<div className={css({ p: '4' })}>
|
|
1289
|
+
<p>Try rapidly toggling the drawer to see smooth animation handling.</p>
|
|
1290
|
+
<Button
|
|
1291
|
+
className={css({ mt: '4' })}
|
|
1292
|
+
onClick={() => handleOpenChange({ open: false })}
|
|
1293
|
+
disabled={isAnimating}
|
|
1294
|
+
>
|
|
1295
|
+
Close from Inside
|
|
1296
|
+
</Button>
|
|
1297
|
+
</div>
|
|
1298
|
+
</Drawer.Body>
|
|
1299
|
+
</Drawer.Content>
|
|
1300
|
+
</Drawer.Positioner>
|
|
1301
|
+
</Drawer.Root>
|
|
1302
|
+
</div>
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
**Best practices:**
|
|
1306
|
+
|
|
1307
|
+
- Track animation state to prevent interaction during transitions
|
|
1308
|
+
- Clear pending timeouts when animations are interrupted
|
|
1309
|
+
- Disable trigger buttons during animation to prevent rapid toggling
|
|
1310
|
+
- Use design system animation durations for consistency
|
|
1311
|
+
- Set `pointer-events: none` on animating elements
|
|
1312
|
+
- Cleanup animation timers on component unmount
|
|
1313
|
+
- Consider using animation events (`onAnimationEnd`) for more precise timing
|
|
1314
|
+
|
|
1315
|
+
---
|
|
1316
|
+
|
|
662
1317
|
## DO NOT
|
|
663
1318
|
|
|
664
1319
|
```typescript
|