@discourser/design-system 0.3.1 → 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 +92 -41
- package/guidelines/components/accordion.md +732 -0
- package/guidelines/components/avatar.md +1015 -0
- package/guidelines/components/badge.md +728 -0
- package/guidelines/components/button.md +75 -40
- package/guidelines/components/card.md +84 -25
- package/guidelines/components/checkbox.md +671 -0
- package/guidelines/components/dialog.md +619 -31
- package/guidelines/components/drawer.md +1616 -0
- package/guidelines/components/heading.md +576 -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 +1271 -0
- package/guidelines/components/progress.md +836 -0
- package/guidelines/components/radio-group.md +852 -0
- package/guidelines/components/select.md +1662 -0
- package/guidelines/components/skeleton.md +802 -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 +1488 -0
- package/guidelines/components/textarea.md +495 -0
- package/guidelines/components/toast.md +784 -0
- package/guidelines/components/tooltip.md +912 -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 +60 -8
- package/guidelines/overview-imports.md +314 -0
- package/guidelines/overview-patterns.md +3852 -0
- package/package.json +4 -2
|
@@ -0,0 +1,1616 @@
|
|
|
1
|
+
# Drawer
|
|
2
|
+
|
|
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
|
+
|
|
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
|
+
|
|
96
|
+
## Import
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { Drawer } from '@discourser/design-system';
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Component Structure
|
|
103
|
+
|
|
104
|
+
Drawer is a compound component built using Ark UI's Dialog primitive. It consists of several parts that work together:
|
|
105
|
+
|
|
106
|
+
### Core Components
|
|
107
|
+
|
|
108
|
+
| Component | Purpose | Required |
|
|
109
|
+
| --------------------- | -------------------------------------- | ----------- |
|
|
110
|
+
| `Drawer.Root` | Main container and state manager | Yes |
|
|
111
|
+
| `Drawer.Trigger` | Element that opens the drawer | Yes |
|
|
112
|
+
| `Drawer.Backdrop` | Semi-transparent overlay behind drawer | Recommended |
|
|
113
|
+
| `Drawer.Positioner` | Positions drawer at screen edge | Yes |
|
|
114
|
+
| `Drawer.Content` | Main drawer panel | Yes |
|
|
115
|
+
| `Drawer.Title` | Drawer heading (for accessibility) | Yes |
|
|
116
|
+
| `Drawer.Description` | Drawer description (for accessibility) | Recommended |
|
|
117
|
+
| `Drawer.CloseTrigger` | Button to close drawer | Recommended |
|
|
118
|
+
|
|
119
|
+
### Layout Components
|
|
120
|
+
|
|
121
|
+
| Component | Purpose | Usage |
|
|
122
|
+
| --------------- | -------------------------------------- | ----------- |
|
|
123
|
+
| `Drawer.Header` | Top section for title and close button | Recommended |
|
|
124
|
+
| `Drawer.Body` | Main scrollable content area | Recommended |
|
|
125
|
+
| `Drawer.Footer` | Bottom section for actions | Optional |
|
|
126
|
+
|
|
127
|
+
### Context
|
|
128
|
+
|
|
129
|
+
| Component | Purpose |
|
|
130
|
+
| ---------------- | ------------------------------------ |
|
|
131
|
+
| `Drawer.Context` | Access drawer state programmatically |
|
|
132
|
+
|
|
133
|
+
## Variants
|
|
134
|
+
|
|
135
|
+
### Placement
|
|
136
|
+
|
|
137
|
+
Controls which edge of the screen the drawer slides from:
|
|
138
|
+
|
|
139
|
+
| Placement | Behavior | Usage |
|
|
140
|
+
| --------- | ------------------------------- | --------------------------------------- |
|
|
141
|
+
| `start` | Slides from left (right in RTL) | Navigation menus, filters |
|
|
142
|
+
| `end` | Slides from right (left in RTL) | Settings, detail panels, shopping carts |
|
|
143
|
+
| `top` | Slides from top | Notifications, announcements |
|
|
144
|
+
| `bottom` | Slides from bottom | Mobile sheets, quick actions |
|
|
145
|
+
|
|
146
|
+
**Default:** `end`
|
|
147
|
+
|
|
148
|
+
**Animation Details:**
|
|
149
|
+
|
|
150
|
+
- `start/end`: Slides horizontally with fade, duration: slowest (open), normal (close)
|
|
151
|
+
- `top/bottom`: Slides vertically with fade, duration: slowest (open), normal (close)
|
|
152
|
+
- Uses emphasized easing curves for smooth, natural motion
|
|
153
|
+
|
|
154
|
+
### Size
|
|
155
|
+
|
|
156
|
+
Controls the width (start/end) or height (top/bottom) of the drawer:
|
|
157
|
+
|
|
158
|
+
| Size | Dimension | Usage |
|
|
159
|
+
| ------ | ------------- | ------------------------------------- |
|
|
160
|
+
| `xs` | 320px (20rem) | Minimal content, mobile-first |
|
|
161
|
+
| `sm` | 384px (24rem) | Standard navigation, compact forms |
|
|
162
|
+
| `md` | 448px (28rem) | Detailed forms, rich content |
|
|
163
|
+
| `lg` | 512px (32rem) | Complex panels, multi-section content |
|
|
164
|
+
| `xl` | 576px (36rem) | Full-featured panels, dashboards |
|
|
165
|
+
| `full` | 100vw/100dvh | Fullscreen mode, mobile takeover |
|
|
166
|
+
|
|
167
|
+
**Default:** `sm`
|
|
168
|
+
|
|
169
|
+
**Note:** For `top` and `bottom` placements, size controls height. For `start` and `end`, it controls width.
|
|
170
|
+
|
|
171
|
+
## Props
|
|
172
|
+
|
|
173
|
+
### Root Props
|
|
174
|
+
|
|
175
|
+
| Prop | Type | Default | Description |
|
|
176
|
+
| ------------------------ | ------------------------------------------------ | -------------- | ------------------------------------ |
|
|
177
|
+
| `placement` | `'start' \| 'end' \| 'top' \| 'bottom'` | `'end'` | Screen edge to slide from |
|
|
178
|
+
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'` | `'sm'` | Width/height of drawer |
|
|
179
|
+
| `open` | `boolean` | - | Controlled open state |
|
|
180
|
+
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
|
|
181
|
+
| `onOpenChange` | `(details: { open: boolean }) => void` | - | Callback when open state changes |
|
|
182
|
+
| `closeOnInteractOutside` | `boolean` | `true` | Close when clicking backdrop |
|
|
183
|
+
| `closeOnEscapeKeyDown` | `boolean` | `true` | Close when pressing Escape |
|
|
184
|
+
| `preventScroll` | `boolean` | `true` | Prevent body scroll when open |
|
|
185
|
+
| `unmountOnExit` | `boolean` | `true` | Remove from DOM when closed |
|
|
186
|
+
| `lazyMount` | `boolean` | `true` | Mount content only when first opened |
|
|
187
|
+
| `modal` | `boolean` | `true` | Trap focus within drawer |
|
|
188
|
+
| `id` | `string` | auto-generated | Unique ID for accessibility |
|
|
189
|
+
|
|
190
|
+
### Content Props
|
|
191
|
+
|
|
192
|
+
All compound components accept standard HTML attributes plus styling props from Panda CSS.
|
|
193
|
+
|
|
194
|
+
## Examples
|
|
195
|
+
|
|
196
|
+
### Basic Usage
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { Drawer, Button } from '@discourser/design-system';
|
|
200
|
+
import { XIcon } from 'your-icon-library';
|
|
201
|
+
|
|
202
|
+
function BasicDrawer() {
|
|
203
|
+
return (
|
|
204
|
+
<Drawer.Root>
|
|
205
|
+
<Drawer.Trigger asChild>
|
|
206
|
+
<Button>Open Drawer</Button>
|
|
207
|
+
</Drawer.Trigger>
|
|
208
|
+
|
|
209
|
+
<Drawer.Backdrop />
|
|
210
|
+
|
|
211
|
+
<Drawer.Positioner>
|
|
212
|
+
<Drawer.Content>
|
|
213
|
+
<Drawer.Header>
|
|
214
|
+
<Drawer.Title>Drawer Title</Drawer.Title>
|
|
215
|
+
<Drawer.Description>
|
|
216
|
+
This is a description of what the drawer contains
|
|
217
|
+
</Drawer.Description>
|
|
218
|
+
<Drawer.CloseTrigger asChild>
|
|
219
|
+
<Button variant="text" size="sm">
|
|
220
|
+
<XIcon />
|
|
221
|
+
</Button>
|
|
222
|
+
</Drawer.CloseTrigger>
|
|
223
|
+
</Drawer.Header>
|
|
224
|
+
|
|
225
|
+
<Drawer.Body>
|
|
226
|
+
<p>Main content goes here</p>
|
|
227
|
+
</Drawer.Body>
|
|
228
|
+
|
|
229
|
+
<Drawer.Footer>
|
|
230
|
+
<Drawer.CloseTrigger asChild>
|
|
231
|
+
<Button variant="outlined">Cancel</Button>
|
|
232
|
+
</Drawer.CloseTrigger>
|
|
233
|
+
<Button>Save</Button>
|
|
234
|
+
</Drawer.Footer>
|
|
235
|
+
</Drawer.Content>
|
|
236
|
+
</Drawer.Positioner>
|
|
237
|
+
</Drawer.Root>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Controlled Drawer
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import { useState } from 'react';
|
|
246
|
+
import { Drawer, Button } from '@discourser/design-system';
|
|
247
|
+
|
|
248
|
+
function ControlledDrawer() {
|
|
249
|
+
const [open, setOpen] = useState(false);
|
|
250
|
+
|
|
251
|
+
const handleSave = () => {
|
|
252
|
+
// Save logic here
|
|
253
|
+
setOpen(false);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<>
|
|
258
|
+
<Button onClick={() => setOpen(true)}>Open Settings</Button>
|
|
259
|
+
|
|
260
|
+
<Drawer.Root open={open} onOpenChange={(details) => setOpen(details.open)}>
|
|
261
|
+
<Drawer.Backdrop />
|
|
262
|
+
<Drawer.Positioner>
|
|
263
|
+
<Drawer.Content>
|
|
264
|
+
<Drawer.Header>
|
|
265
|
+
<Drawer.Title>Settings</Drawer.Title>
|
|
266
|
+
<Drawer.CloseTrigger asChild>
|
|
267
|
+
<Button variant="text" size="sm">
|
|
268
|
+
<XIcon />
|
|
269
|
+
</Button>
|
|
270
|
+
</Drawer.CloseTrigger>
|
|
271
|
+
</Drawer.Header>
|
|
272
|
+
|
|
273
|
+
<Drawer.Body>
|
|
274
|
+
{/* Settings form */}
|
|
275
|
+
</Drawer.Body>
|
|
276
|
+
|
|
277
|
+
<Drawer.Footer>
|
|
278
|
+
<Button variant="outlined" onClick={() => setOpen(false)}>
|
|
279
|
+
Cancel
|
|
280
|
+
</Button>
|
|
281
|
+
<Button onClick={handleSave}>Save Changes</Button>
|
|
282
|
+
</Drawer.Footer>
|
|
283
|
+
</Drawer.Content>
|
|
284
|
+
</Drawer.Positioner>
|
|
285
|
+
</Drawer.Root>
|
|
286
|
+
</>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Different Placements
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
// Navigation drawer (left side)
|
|
295
|
+
<Drawer.Root placement="start" size="sm">
|
|
296
|
+
<Drawer.Trigger asChild>
|
|
297
|
+
<Button leftIcon={<MenuIcon />}>Menu</Button>
|
|
298
|
+
</Drawer.Trigger>
|
|
299
|
+
<Drawer.Backdrop />
|
|
300
|
+
<Drawer.Positioner>
|
|
301
|
+
<Drawer.Content>
|
|
302
|
+
<Drawer.Header>
|
|
303
|
+
<Drawer.Title>Navigation</Drawer.Title>
|
|
304
|
+
</Drawer.Header>
|
|
305
|
+
<Drawer.Body>
|
|
306
|
+
<nav>
|
|
307
|
+
<a href="/home">Home</a>
|
|
308
|
+
<a href="/about">About</a>
|
|
309
|
+
<a href="/contact">Contact</a>
|
|
310
|
+
</nav>
|
|
311
|
+
</Drawer.Body>
|
|
312
|
+
</Drawer.Content>
|
|
313
|
+
</Drawer.Positioner>
|
|
314
|
+
</Drawer.Root>
|
|
315
|
+
|
|
316
|
+
// Shopping cart drawer (right side)
|
|
317
|
+
<Drawer.Root placement="end" size="md">
|
|
318
|
+
<Drawer.Trigger asChild>
|
|
319
|
+
<Button rightIcon={<CartIcon />}>Cart (3)</Button>
|
|
320
|
+
</Drawer.Trigger>
|
|
321
|
+
<Drawer.Backdrop />
|
|
322
|
+
<Drawer.Positioner>
|
|
323
|
+
<Drawer.Content>
|
|
324
|
+
<Drawer.Header>
|
|
325
|
+
<Drawer.Title>Shopping Cart</Drawer.Title>
|
|
326
|
+
</Drawer.Header>
|
|
327
|
+
<Drawer.Body>
|
|
328
|
+
{/* Cart items */}
|
|
329
|
+
</Drawer.Body>
|
|
330
|
+
<Drawer.Footer>
|
|
331
|
+
<Button variant="filled">Checkout</Button>
|
|
332
|
+
</Drawer.Footer>
|
|
333
|
+
</Drawer.Content>
|
|
334
|
+
</Drawer.Positioner>
|
|
335
|
+
</Drawer.Root>
|
|
336
|
+
|
|
337
|
+
// Mobile bottom sheet
|
|
338
|
+
<Drawer.Root placement="bottom" size="md">
|
|
339
|
+
<Drawer.Trigger asChild>
|
|
340
|
+
<Button>Share</Button>
|
|
341
|
+
</Drawer.Trigger>
|
|
342
|
+
<Drawer.Backdrop />
|
|
343
|
+
<Drawer.Positioner>
|
|
344
|
+
<Drawer.Content>
|
|
345
|
+
<Drawer.Header>
|
|
346
|
+
<Drawer.Title>Share Options</Drawer.Title>
|
|
347
|
+
</Drawer.Header>
|
|
348
|
+
<Drawer.Body>
|
|
349
|
+
{/* Share options */}
|
|
350
|
+
</Drawer.Body>
|
|
351
|
+
</Drawer.Content>
|
|
352
|
+
</Drawer.Positioner>
|
|
353
|
+
</Drawer.Root>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Different Sizes
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// Compact drawer for filters
|
|
360
|
+
<Drawer.Root size="xs">
|
|
361
|
+
<Drawer.Trigger asChild>
|
|
362
|
+
<Button>Filters</Button>
|
|
363
|
+
</Drawer.Trigger>
|
|
364
|
+
<Drawer.Backdrop />
|
|
365
|
+
<Drawer.Positioner>
|
|
366
|
+
<Drawer.Content>
|
|
367
|
+
<Drawer.Header>
|
|
368
|
+
<Drawer.Title>Filter Results</Drawer.Title>
|
|
369
|
+
</Drawer.Header>
|
|
370
|
+
<Drawer.Body>
|
|
371
|
+
{/* Compact filter options */}
|
|
372
|
+
</Drawer.Body>
|
|
373
|
+
</Drawer.Content>
|
|
374
|
+
</Drawer.Positioner>
|
|
375
|
+
</Drawer.Root>
|
|
376
|
+
|
|
377
|
+
// Large drawer for detailed content
|
|
378
|
+
<Drawer.Root size="lg">
|
|
379
|
+
<Drawer.Trigger asChild>
|
|
380
|
+
<Button>View Details</Button>
|
|
381
|
+
</Drawer.Trigger>
|
|
382
|
+
<Drawer.Backdrop />
|
|
383
|
+
<Drawer.Positioner>
|
|
384
|
+
<Drawer.Content>
|
|
385
|
+
<Drawer.Header>
|
|
386
|
+
<Drawer.Title>Product Details</Drawer.Title>
|
|
387
|
+
</Drawer.Header>
|
|
388
|
+
<Drawer.Body>
|
|
389
|
+
{/* Rich content with images, descriptions, etc. */}
|
|
390
|
+
</Drawer.Body>
|
|
391
|
+
</Drawer.Content>
|
|
392
|
+
</Drawer.Positioner>
|
|
393
|
+
</Drawer.Root>
|
|
394
|
+
|
|
395
|
+
// Fullscreen drawer for mobile
|
|
396
|
+
<Drawer.Root size="full">
|
|
397
|
+
<Drawer.Trigger asChild>
|
|
398
|
+
<Button>Edit Profile</Button>
|
|
399
|
+
</Drawer.Trigger>
|
|
400
|
+
<Drawer.Positioner>
|
|
401
|
+
<Drawer.Content>
|
|
402
|
+
<Drawer.Header>
|
|
403
|
+
<Drawer.Title>Edit Profile</Drawer.Title>
|
|
404
|
+
</Drawer.Header>
|
|
405
|
+
<Drawer.Body>
|
|
406
|
+
{/* Full editing interface */}
|
|
407
|
+
</Drawer.Body>
|
|
408
|
+
<Drawer.Footer>
|
|
409
|
+
<Button>Save Changes</Button>
|
|
410
|
+
</Drawer.Footer>
|
|
411
|
+
</Drawer.Content>
|
|
412
|
+
</Drawer.Positioner>
|
|
413
|
+
</Drawer.Root>
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Form in Drawer
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
import { Drawer, Button, Input, Textarea } from '@discourser/design-system';
|
|
420
|
+
import { useState } from 'react';
|
|
421
|
+
|
|
422
|
+
function FormDrawer() {
|
|
423
|
+
const [formData, setFormData] = useState({ name: '', email: '', message: '' });
|
|
424
|
+
|
|
425
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
426
|
+
e.preventDefault();
|
|
427
|
+
// Submit form
|
|
428
|
+
console.log('Form submitted:', formData);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<Drawer.Root placement="end" size="md">
|
|
433
|
+
<Drawer.Trigger asChild>
|
|
434
|
+
<Button>Contact Us</Button>
|
|
435
|
+
</Drawer.Trigger>
|
|
436
|
+
|
|
437
|
+
<Drawer.Backdrop />
|
|
438
|
+
|
|
439
|
+
<Drawer.Positioner>
|
|
440
|
+
<Drawer.Content>
|
|
441
|
+
<form onSubmit={handleSubmit}>
|
|
442
|
+
<Drawer.Header>
|
|
443
|
+
<Drawer.Title>Contact Form</Drawer.Title>
|
|
444
|
+
<Drawer.Description>
|
|
445
|
+
Send us a message and we'll get back to you soon
|
|
446
|
+
</Drawer.Description>
|
|
447
|
+
<Drawer.CloseTrigger asChild>
|
|
448
|
+
<Button variant="text" size="sm" type="button">
|
|
449
|
+
<XIcon />
|
|
450
|
+
</Button>
|
|
451
|
+
</Drawer.CloseTrigger>
|
|
452
|
+
</Drawer.Header>
|
|
453
|
+
|
|
454
|
+
<Drawer.Body>
|
|
455
|
+
<Input
|
|
456
|
+
label="Name"
|
|
457
|
+
value={formData.name}
|
|
458
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
459
|
+
required
|
|
460
|
+
/>
|
|
461
|
+
<Input
|
|
462
|
+
label="Email"
|
|
463
|
+
type="email"
|
|
464
|
+
value={formData.email}
|
|
465
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
466
|
+
required
|
|
467
|
+
/>
|
|
468
|
+
<Textarea
|
|
469
|
+
label="Message"
|
|
470
|
+
value={formData.message}
|
|
471
|
+
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
|
472
|
+
rows={5}
|
|
473
|
+
required
|
|
474
|
+
/>
|
|
475
|
+
</Drawer.Body>
|
|
476
|
+
|
|
477
|
+
<Drawer.Footer>
|
|
478
|
+
<Drawer.CloseTrigger asChild>
|
|
479
|
+
<Button variant="outlined" type="button">Cancel</Button>
|
|
480
|
+
</Drawer.CloseTrigger>
|
|
481
|
+
<Button type="submit">Send Message</Button>
|
|
482
|
+
</Drawer.Footer>
|
|
483
|
+
</form>
|
|
484
|
+
</Drawer.Content>
|
|
485
|
+
</Drawer.Positioner>
|
|
486
|
+
</Drawer.Root>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### Navigation Menu Drawer
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
import { Drawer, Button, IconButton } from '@discourser/design-system';
|
|
495
|
+
import { MenuIcon, HomeIcon, SettingsIcon, UserIcon, XIcon } from 'your-icon-library';
|
|
496
|
+
|
|
497
|
+
function NavigationDrawer() {
|
|
498
|
+
const menuItems = [
|
|
499
|
+
{ icon: <HomeIcon />, label: 'Home', href: '/' },
|
|
500
|
+
{ icon: <UserIcon />, label: 'Profile', href: '/profile' },
|
|
501
|
+
{ icon: <SettingsIcon />, label: 'Settings', href: '/settings' },
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<Drawer.Root placement="start" size="sm">
|
|
506
|
+
<Drawer.Trigger asChild>
|
|
507
|
+
<IconButton aria-label="Open menu">
|
|
508
|
+
<MenuIcon />
|
|
509
|
+
</IconButton>
|
|
510
|
+
</Drawer.Trigger>
|
|
511
|
+
|
|
512
|
+
<Drawer.Backdrop />
|
|
513
|
+
|
|
514
|
+
<Drawer.Positioner>
|
|
515
|
+
<Drawer.Content>
|
|
516
|
+
<Drawer.Header>
|
|
517
|
+
<Drawer.Title>Menu</Drawer.Title>
|
|
518
|
+
<Drawer.CloseTrigger asChild>
|
|
519
|
+
<IconButton aria-label="Close menu" variant="text" size="sm">
|
|
520
|
+
<XIcon />
|
|
521
|
+
</IconButton>
|
|
522
|
+
</Drawer.CloseTrigger>
|
|
523
|
+
</Drawer.Header>
|
|
524
|
+
|
|
525
|
+
<Drawer.Body>
|
|
526
|
+
<nav className={css({ display: 'flex', flexDirection: 'column', gap: '2' })}>
|
|
527
|
+
{menuItems.map((item) => (
|
|
528
|
+
<a
|
|
529
|
+
key={item.href}
|
|
530
|
+
href={item.href}
|
|
531
|
+
className={css({
|
|
532
|
+
display: 'flex',
|
|
533
|
+
alignItems: 'center',
|
|
534
|
+
gap: '3',
|
|
535
|
+
p: '3',
|
|
536
|
+
borderRadius: 'md',
|
|
537
|
+
color: 'fg.default',
|
|
538
|
+
textDecoration: 'none',
|
|
539
|
+
_hover: { bg: 'gray.a3' },
|
|
540
|
+
})}
|
|
541
|
+
>
|
|
542
|
+
{item.icon}
|
|
543
|
+
<span>{item.label}</span>
|
|
544
|
+
</a>
|
|
545
|
+
))}
|
|
546
|
+
</nav>
|
|
547
|
+
</Drawer.Body>
|
|
548
|
+
</Drawer.Content>
|
|
549
|
+
</Drawer.Positioner>
|
|
550
|
+
</Drawer.Root>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### Using Context
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
import { Drawer } from '@discourser/design-system';
|
|
559
|
+
|
|
560
|
+
function DrawerWithContext() {
|
|
561
|
+
return (
|
|
562
|
+
<Drawer.Root>
|
|
563
|
+
<Drawer.Trigger asChild>
|
|
564
|
+
<Button>Open</Button>
|
|
565
|
+
</Drawer.Trigger>
|
|
566
|
+
|
|
567
|
+
<Drawer.Backdrop />
|
|
568
|
+
|
|
569
|
+
<Drawer.Positioner>
|
|
570
|
+
<Drawer.Content>
|
|
571
|
+
<Drawer.Header>
|
|
572
|
+
<Drawer.Title>Custom Content</Drawer.Title>
|
|
573
|
+
</Drawer.Header>
|
|
574
|
+
|
|
575
|
+
<Drawer.Body>
|
|
576
|
+
<Drawer.Context>
|
|
577
|
+
{(context) => (
|
|
578
|
+
<div>
|
|
579
|
+
<p>Drawer is {context.open ? 'open' : 'closed'}</p>
|
|
580
|
+
<Button onClick={() => context.setOpen(false)}>
|
|
581
|
+
Close Programmatically
|
|
582
|
+
</Button>
|
|
583
|
+
</div>
|
|
584
|
+
)}
|
|
585
|
+
</Drawer.Context>
|
|
586
|
+
</Drawer.Body>
|
|
587
|
+
</Drawer.Content>
|
|
588
|
+
</Drawer.Positioner>
|
|
589
|
+
</Drawer.Root>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
## Common Patterns
|
|
595
|
+
|
|
596
|
+
### Confirmation Before Close
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
function ConfirmCloseDrawer() {
|
|
600
|
+
const [hasChanges, setHasChanges] = useState(false);
|
|
601
|
+
const [showConfirm, setShowConfirm] = useState(false);
|
|
602
|
+
|
|
603
|
+
const handleInteractOutside = (e: Event) => {
|
|
604
|
+
if (hasChanges) {
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
setShowConfirm(true);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
<>
|
|
612
|
+
<Drawer.Root onInteractOutside={handleInteractOutside}>
|
|
613
|
+
<Drawer.Trigger asChild>
|
|
614
|
+
<Button>Edit Settings</Button>
|
|
615
|
+
</Drawer.Trigger>
|
|
616
|
+
|
|
617
|
+
<Drawer.Backdrop />
|
|
618
|
+
|
|
619
|
+
<Drawer.Positioner>
|
|
620
|
+
<Drawer.Content>
|
|
621
|
+
<Drawer.Header>
|
|
622
|
+
<Drawer.Title>Settings</Drawer.Title>
|
|
623
|
+
</Drawer.Header>
|
|
624
|
+
|
|
625
|
+
<Drawer.Body>
|
|
626
|
+
<Input onChange={() => setHasChanges(true)} />
|
|
627
|
+
</Drawer.Body>
|
|
628
|
+
|
|
629
|
+
<Drawer.Footer>
|
|
630
|
+
<Button>Save</Button>
|
|
631
|
+
</Drawer.Footer>
|
|
632
|
+
</Drawer.Content>
|
|
633
|
+
</Drawer.Positioner>
|
|
634
|
+
</Drawer.Root>
|
|
635
|
+
|
|
636
|
+
{showConfirm && (
|
|
637
|
+
<Dialog.Root open onOpenChange={(e) => setShowConfirm(e.open)}>
|
|
638
|
+
<Dialog.Content>
|
|
639
|
+
<Dialog.Title>Unsaved Changes</Dialog.Title>
|
|
640
|
+
<Dialog.Description>
|
|
641
|
+
You have unsaved changes. Are you sure you want to close?
|
|
642
|
+
</Dialog.Description>
|
|
643
|
+
<Dialog.Footer>
|
|
644
|
+
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
|
645
|
+
Cancel
|
|
646
|
+
</Button>
|
|
647
|
+
<Button onClick={() => {
|
|
648
|
+
setShowConfirm(false);
|
|
649
|
+
setHasChanges(false);
|
|
650
|
+
}}>
|
|
651
|
+
Discard Changes
|
|
652
|
+
</Button>
|
|
653
|
+
</Dialog.Footer>
|
|
654
|
+
</Dialog.Content>
|
|
655
|
+
</Dialog.Root>
|
|
656
|
+
)}
|
|
657
|
+
</>
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Multi-Step Drawer
|
|
663
|
+
|
|
664
|
+
```typescript
|
|
665
|
+
function MultiStepDrawer() {
|
|
666
|
+
const [step, setStep] = useState(1);
|
|
667
|
+
|
|
668
|
+
return (
|
|
669
|
+
<Drawer.Root size="md">
|
|
670
|
+
<Drawer.Trigger asChild>
|
|
671
|
+
<Button>Start Wizard</Button>
|
|
672
|
+
</Drawer.Trigger>
|
|
673
|
+
|
|
674
|
+
<Drawer.Backdrop />
|
|
675
|
+
|
|
676
|
+
<Drawer.Positioner>
|
|
677
|
+
<Drawer.Content>
|
|
678
|
+
<Drawer.Header>
|
|
679
|
+
<Drawer.Title>Setup Wizard - Step {step} of 3</Drawer.Title>
|
|
680
|
+
<Drawer.CloseTrigger asChild>
|
|
681
|
+
<IconButton aria-label="Close" variant="text" size="sm">
|
|
682
|
+
<XIcon />
|
|
683
|
+
</IconButton>
|
|
684
|
+
</Drawer.CloseTrigger>
|
|
685
|
+
</Drawer.Header>
|
|
686
|
+
|
|
687
|
+
<Drawer.Body>
|
|
688
|
+
{step === 1 && <div>Step 1 content</div>}
|
|
689
|
+
{step === 2 && <div>Step 2 content</div>}
|
|
690
|
+
{step === 3 && <div>Step 3 content</div>}
|
|
691
|
+
</Drawer.Body>
|
|
692
|
+
|
|
693
|
+
<Drawer.Footer>
|
|
694
|
+
{step > 1 && (
|
|
695
|
+
<Button variant="outlined" onClick={() => setStep(step - 1)}>
|
|
696
|
+
Back
|
|
697
|
+
</Button>
|
|
698
|
+
)}
|
|
699
|
+
{step < 3 ? (
|
|
700
|
+
<Button onClick={() => setStep(step + 1)}>Next</Button>
|
|
701
|
+
) : (
|
|
702
|
+
<Button>Finish</Button>
|
|
703
|
+
)}
|
|
704
|
+
</Drawer.Footer>
|
|
705
|
+
</Drawer.Content>
|
|
706
|
+
</Drawer.Positioner>
|
|
707
|
+
</Drawer.Root>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### Responsive Drawer
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
function ResponsiveDrawer() {
|
|
716
|
+
return (
|
|
717
|
+
<Drawer.Root
|
|
718
|
+
placement={{ base: 'bottom', md: 'end' }}
|
|
719
|
+
size={{ base: 'md', md: 'sm' }}
|
|
720
|
+
>
|
|
721
|
+
<Drawer.Trigger asChild>
|
|
722
|
+
<Button>Open Filters</Button>
|
|
723
|
+
</Drawer.Trigger>
|
|
724
|
+
|
|
725
|
+
<Drawer.Backdrop />
|
|
726
|
+
|
|
727
|
+
<Drawer.Positioner>
|
|
728
|
+
<Drawer.Content>
|
|
729
|
+
<Drawer.Header>
|
|
730
|
+
<Drawer.Title>Filters</Drawer.Title>
|
|
731
|
+
<Drawer.CloseTrigger asChild>
|
|
732
|
+
<IconButton aria-label="Close" variant="text" size="sm">
|
|
733
|
+
<XIcon />
|
|
734
|
+
</IconButton>
|
|
735
|
+
</Drawer.CloseTrigger>
|
|
736
|
+
</Drawer.Header>
|
|
737
|
+
|
|
738
|
+
<Drawer.Body>
|
|
739
|
+
{/* Filter options - layout adapts to placement */}
|
|
740
|
+
</Drawer.Body>
|
|
741
|
+
|
|
742
|
+
<Drawer.Footer>
|
|
743
|
+
<Button variant="outlined">Clear</Button>
|
|
744
|
+
<Button>Apply Filters</Button>
|
|
745
|
+
</Drawer.Footer>
|
|
746
|
+
</Drawer.Content>
|
|
747
|
+
</Drawer.Positioner>
|
|
748
|
+
</Drawer.Root>
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
```
|
|
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
|
+
|
|
1317
|
+
## DO NOT
|
|
1318
|
+
|
|
1319
|
+
```typescript
|
|
1320
|
+
// ❌ Don't omit Backdrop (unless intentional)
|
|
1321
|
+
<Drawer.Root>
|
|
1322
|
+
<Drawer.Positioner>
|
|
1323
|
+
<Drawer.Content>...</Drawer.Content>
|
|
1324
|
+
</Drawer.Positioner>
|
|
1325
|
+
</Drawer.Root>
|
|
1326
|
+
|
|
1327
|
+
// ❌ Don't forget Positioner wrapper
|
|
1328
|
+
<Drawer.Root>
|
|
1329
|
+
<Drawer.Backdrop />
|
|
1330
|
+
<Drawer.Content>...</Drawer.Content> // Missing Positioner
|
|
1331
|
+
</Drawer.Root>
|
|
1332
|
+
|
|
1333
|
+
// ❌ Don't omit Title (required for accessibility)
|
|
1334
|
+
<Drawer.Content>
|
|
1335
|
+
<Drawer.Body>
|
|
1336
|
+
Content without title
|
|
1337
|
+
</Drawer.Body>
|
|
1338
|
+
</Drawer.Content>
|
|
1339
|
+
|
|
1340
|
+
// ❌ Don't use for critical alerts or confirmations (use Dialog instead)
|
|
1341
|
+
<Drawer.Root>
|
|
1342
|
+
<Drawer.Content>
|
|
1343
|
+
<Drawer.Title>Delete Account?</Drawer.Title>
|
|
1344
|
+
<Drawer.Body>This action cannot be undone</Drawer.Body>
|
|
1345
|
+
</Drawer.Content>
|
|
1346
|
+
</Drawer.Root>
|
|
1347
|
+
|
|
1348
|
+
// ❌ Don't nest drawers
|
|
1349
|
+
<Drawer.Root>
|
|
1350
|
+
<Drawer.Content>
|
|
1351
|
+
<Drawer.Root> // Don't nest
|
|
1352
|
+
<Drawer.Content>...</Drawer.Content>
|
|
1353
|
+
</Drawer.Root>
|
|
1354
|
+
</Drawer.Content>
|
|
1355
|
+
</Drawer.Root>
|
|
1356
|
+
|
|
1357
|
+
// ❌ Don't use oversized drawers for simple content
|
|
1358
|
+
<Drawer.Root size="xl">
|
|
1359
|
+
<Drawer.Content>
|
|
1360
|
+
<Drawer.Body>
|
|
1361
|
+
<p>Just a simple message</p> // Use smaller size
|
|
1362
|
+
</Drawer.Body>
|
|
1363
|
+
</Drawer.Content>
|
|
1364
|
+
</Drawer.Root>
|
|
1365
|
+
|
|
1366
|
+
// ❌ Don't put primary navigation in end-placed drawer
|
|
1367
|
+
<Drawer.Root placement="end"> // Use placement="start" for navigation
|
|
1368
|
+
<Drawer.Content>
|
|
1369
|
+
<nav>Main navigation menu</nav>
|
|
1370
|
+
</Drawer.Content>
|
|
1371
|
+
</Drawer.Root>
|
|
1372
|
+
|
|
1373
|
+
// ✅ Correct usage
|
|
1374
|
+
<Drawer.Root placement="start">
|
|
1375
|
+
<Drawer.Trigger asChild>
|
|
1376
|
+
<Button>Menu</Button>
|
|
1377
|
+
</Drawer.Trigger>
|
|
1378
|
+
<Drawer.Backdrop />
|
|
1379
|
+
<Drawer.Positioner>
|
|
1380
|
+
<Drawer.Content>
|
|
1381
|
+
<Drawer.Header>
|
|
1382
|
+
<Drawer.Title>Navigation</Drawer.Title>
|
|
1383
|
+
<Drawer.CloseTrigger asChild>
|
|
1384
|
+
<IconButton aria-label="Close menu">
|
|
1385
|
+
<XIcon />
|
|
1386
|
+
</IconButton>
|
|
1387
|
+
</Drawer.CloseTrigger>
|
|
1388
|
+
</Drawer.Header>
|
|
1389
|
+
<Drawer.Body>
|
|
1390
|
+
<nav>Navigation items</nav>
|
|
1391
|
+
</Drawer.Body>
|
|
1392
|
+
</Drawer.Content>
|
|
1393
|
+
</Drawer.Positioner>
|
|
1394
|
+
</Drawer.Root>
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
## Accessibility
|
|
1398
|
+
|
|
1399
|
+
The Drawer component follows WCAG 2.1 Level AA standards:
|
|
1400
|
+
|
|
1401
|
+
- **Focus Management**: Focus trapped within drawer when open
|
|
1402
|
+
- **Keyboard Navigation**:
|
|
1403
|
+
- `Escape` key closes drawer
|
|
1404
|
+
- `Tab` cycles through focusable elements
|
|
1405
|
+
- Focus returns to trigger on close
|
|
1406
|
+
- **Screen Reader Support**:
|
|
1407
|
+
- Announced as dialog/modal
|
|
1408
|
+
- Title required for proper announcement
|
|
1409
|
+
- Description recommended for context
|
|
1410
|
+
- **ARIA Attributes**:
|
|
1411
|
+
- `role="dialog"` on Content
|
|
1412
|
+
- `aria-modal="true"` when modal
|
|
1413
|
+
- `aria-labelledby` references Title
|
|
1414
|
+
- `aria-describedby` references Description
|
|
1415
|
+
- **Body Scroll Lock**: Prevents background scrolling when open
|
|
1416
|
+
|
|
1417
|
+
### Accessibility Best Practices
|
|
1418
|
+
|
|
1419
|
+
```typescript
|
|
1420
|
+
// ✅ Always provide Title
|
|
1421
|
+
<Drawer.Content>
|
|
1422
|
+
<Drawer.Header>
|
|
1423
|
+
<Drawer.Title>Settings</Drawer.Title>
|
|
1424
|
+
</Drawer.Header>
|
|
1425
|
+
</Drawer.Content>
|
|
1426
|
+
|
|
1427
|
+
// ✅ Add Description for complex drawers
|
|
1428
|
+
<Drawer.Header>
|
|
1429
|
+
<Drawer.Title>Export Data</Drawer.Title>
|
|
1430
|
+
<Drawer.Description>
|
|
1431
|
+
Choose format and options for exporting your data
|
|
1432
|
+
</Drawer.Description>
|
|
1433
|
+
</Drawer.Header>
|
|
1434
|
+
|
|
1435
|
+
// ✅ Label close buttons
|
|
1436
|
+
<Drawer.CloseTrigger asChild>
|
|
1437
|
+
<IconButton aria-label="Close drawer">
|
|
1438
|
+
<XIcon />
|
|
1439
|
+
</IconButton>
|
|
1440
|
+
</Drawer.CloseTrigger>
|
|
1441
|
+
|
|
1442
|
+
// ✅ Use semantic HTML in content
|
|
1443
|
+
<Drawer.Body>
|
|
1444
|
+
<nav aria-label="Main navigation">
|
|
1445
|
+
<ul>
|
|
1446
|
+
<li><a href="/">Home</a></li>
|
|
1447
|
+
<li><a href="/about">About</a></li>
|
|
1448
|
+
</ul>
|
|
1449
|
+
</nav>
|
|
1450
|
+
</Drawer.Body>
|
|
1451
|
+
|
|
1452
|
+
// ✅ Announce dynamic changes
|
|
1453
|
+
<Drawer.Body>
|
|
1454
|
+
<form aria-live="polite">
|
|
1455
|
+
{/* Form with validation messages */}
|
|
1456
|
+
</form>
|
|
1457
|
+
</Drawer.Body>
|
|
1458
|
+
```
|
|
1459
|
+
|
|
1460
|
+
## Usage Guidelines
|
|
1461
|
+
|
|
1462
|
+
### When to Use Drawer
|
|
1463
|
+
|
|
1464
|
+
| Use Case | Why Drawer |
|
|
1465
|
+
| --------------- | --------------------------------------------- |
|
|
1466
|
+
| Navigation menu | Slides from side, doesn't block entire screen |
|
|
1467
|
+
| Filter panel | Related to page content, easy to dismiss |
|
|
1468
|
+
| Shopping cart | Contextual preview without leaving page |
|
|
1469
|
+
| Settings panel | Secondary actions, can stay on current page |
|
|
1470
|
+
| Detail view | Additional info without full page navigation |
|
|
1471
|
+
| Form/wizard | Multi-step process without modal interruption |
|
|
1472
|
+
|
|
1473
|
+
### When NOT to Use Drawer
|
|
1474
|
+
|
|
1475
|
+
| Use Case | Use Instead | Why |
|
|
1476
|
+
| ---------------------- | --------------- | -------------------------------------------- |
|
|
1477
|
+
| Critical confirmations | Dialog | Center focus, harder to dismiss accidentally |
|
|
1478
|
+
| Short messages | Toast/Alert | Drawer is too heavy for simple notifications |
|
|
1479
|
+
| Quick tips | Tooltip/Popover | Drawer is overkill for small hints |
|
|
1480
|
+
| Full page forms | Separate route | Better for complex, primary content |
|
|
1481
|
+
| Nested panels | Tabs/Accordion | Avoid drawer inception |
|
|
1482
|
+
|
|
1483
|
+
## Placement Guidelines
|
|
1484
|
+
|
|
1485
|
+
| Placement | Best For | Direction Support |
|
|
1486
|
+
| --------- | ------------------------- | ------------------------- |
|
|
1487
|
+
| `start` | Primary navigation, menus | LTR: left, RTL: right |
|
|
1488
|
+
| `end` | Carts, details, settings | LTR: right, RTL: left |
|
|
1489
|
+
| `top` | Notifications, banners | Top edge (all locales) |
|
|
1490
|
+
| `bottom` | Mobile sheets, actions | Bottom edge (all locales) |
|
|
1491
|
+
|
|
1492
|
+
## Size Guidelines
|
|
1493
|
+
|
|
1494
|
+
| Size | Width/Height | Best For |
|
|
1495
|
+
| ------ | ------------ | --------------------------------- |
|
|
1496
|
+
| `xs` | 320px | Minimal menus, quick filters |
|
|
1497
|
+
| `sm` | 384px | Standard navigation, simple forms |
|
|
1498
|
+
| `md` | 448px | Detailed content, shopping cart |
|
|
1499
|
+
| `lg` | 512px | Rich panels, multi-section forms |
|
|
1500
|
+
| `xl` | 576px | Dashboard panels, complex content |
|
|
1501
|
+
| `full` | 100% | Mobile takeover, full editing |
|
|
1502
|
+
|
|
1503
|
+
## State Behaviors
|
|
1504
|
+
|
|
1505
|
+
| State | Visual Change | Behavior |
|
|
1506
|
+
| ----------- | ---------------------- | ----------------------------------------- |
|
|
1507
|
+
| **Opening** | Slides in + fades in | Duration: slowest (emphasized-in easing) |
|
|
1508
|
+
| **Open** | Fully visible | Focus trapped, body scroll locked |
|
|
1509
|
+
| **Closing** | Slides out + fades out | Duration: normal (emphasized-out easing) |
|
|
1510
|
+
| **Closed** | Removed from DOM | Focus returns to trigger, scroll restored |
|
|
1511
|
+
|
|
1512
|
+
## Testing
|
|
1513
|
+
|
|
1514
|
+
When testing Drawer components:
|
|
1515
|
+
|
|
1516
|
+
```typescript
|
|
1517
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
1518
|
+
import userEvent from '@testing-library/user-event';
|
|
1519
|
+
import { Drawer, Button } from '@discourser/design-system';
|
|
1520
|
+
|
|
1521
|
+
test('drawer opens and closes', async () => {
|
|
1522
|
+
const user = userEvent.setup();
|
|
1523
|
+
|
|
1524
|
+
render(
|
|
1525
|
+
<Drawer.Root>
|
|
1526
|
+
<Drawer.Trigger asChild>
|
|
1527
|
+
<Button>Open</Button>
|
|
1528
|
+
</Drawer.Trigger>
|
|
1529
|
+
<Drawer.Backdrop />
|
|
1530
|
+
<Drawer.Positioner>
|
|
1531
|
+
<Drawer.Content>
|
|
1532
|
+
<Drawer.Title>Test Drawer</Drawer.Title>
|
|
1533
|
+
<Drawer.Body>Content</Drawer.Body>
|
|
1534
|
+
<Drawer.CloseTrigger asChild>
|
|
1535
|
+
<Button>Close</Button>
|
|
1536
|
+
</Drawer.CloseTrigger>
|
|
1537
|
+
</Drawer.Content>
|
|
1538
|
+
</Drawer.Positioner>
|
|
1539
|
+
</Drawer.Root>
|
|
1540
|
+
);
|
|
1541
|
+
|
|
1542
|
+
// Initially closed
|
|
1543
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
1544
|
+
|
|
1545
|
+
// Open drawer
|
|
1546
|
+
await user.click(screen.getByText('Open'));
|
|
1547
|
+
|
|
1548
|
+
await waitFor(() => {
|
|
1549
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
1550
|
+
expect(screen.getByText('Test Drawer')).toBeInTheDocument();
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
// Close drawer
|
|
1554
|
+
await user.click(screen.getByText('Close'));
|
|
1555
|
+
|
|
1556
|
+
await waitFor(() => {
|
|
1557
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
1558
|
+
});
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
test('drawer closes on backdrop click', async () => {
|
|
1562
|
+
const user = userEvent.setup();
|
|
1563
|
+
const onOpenChange = vi.fn();
|
|
1564
|
+
|
|
1565
|
+
render(
|
|
1566
|
+
<Drawer.Root onOpenChange={onOpenChange}>
|
|
1567
|
+
<Drawer.Trigger asChild>
|
|
1568
|
+
<Button>Open</Button>
|
|
1569
|
+
</Drawer.Trigger>
|
|
1570
|
+
<Drawer.Backdrop />
|
|
1571
|
+
<Drawer.Positioner>
|
|
1572
|
+
<Drawer.Content>
|
|
1573
|
+
<Drawer.Title>Test</Drawer.Title>
|
|
1574
|
+
</Drawer.Content>
|
|
1575
|
+
</Drawer.Positioner>
|
|
1576
|
+
</Drawer.Root>
|
|
1577
|
+
);
|
|
1578
|
+
|
|
1579
|
+
await user.click(screen.getByText('Open'));
|
|
1580
|
+
|
|
1581
|
+
const backdrop = screen.getByRole('dialog').parentElement;
|
|
1582
|
+
await user.click(backdrop!);
|
|
1583
|
+
|
|
1584
|
+
expect(onOpenChange).toHaveBeenCalledWith({ open: false });
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
test('drawer closes on escape key', async () => {
|
|
1588
|
+
const user = userEvent.setup();
|
|
1589
|
+
|
|
1590
|
+
render(
|
|
1591
|
+
<Drawer.Root defaultOpen>
|
|
1592
|
+
<Drawer.Positioner>
|
|
1593
|
+
<Drawer.Content>
|
|
1594
|
+
<Drawer.Title>Test</Drawer.Title>
|
|
1595
|
+
</Drawer.Content>
|
|
1596
|
+
</Drawer.Positioner>
|
|
1597
|
+
</Drawer.Root>
|
|
1598
|
+
);
|
|
1599
|
+
|
|
1600
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
1601
|
+
|
|
1602
|
+
await user.keyboard('{Escape}');
|
|
1603
|
+
|
|
1604
|
+
await waitFor(() => {
|
|
1605
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1608
|
+
```
|
|
1609
|
+
|
|
1610
|
+
## Related Components
|
|
1611
|
+
|
|
1612
|
+
- **Dialog**: Use for centered modals and critical confirmations
|
|
1613
|
+
- **Popover**: Use for contextual menus and tooltips
|
|
1614
|
+
- **Sheet**: Mobile-specific bottom sheet (Drawer with `placement="bottom"`)
|
|
1615
|
+
- **Menu**: Use for dropdown menus and context menus
|
|
1616
|
+
- **Tooltip**: Use for simple hints and help text
|