torch-glare 2.1.5 → 2.2.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/apps/lib/components/BadgeField.tsx +131 -12
- package/apps/lib/components/ContextMenu.tsx +524 -0
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +1 -1
- package/apps/lib/components/Drawer.tsx +23 -4
- package/apps/lib/components/DropdownMenu.tsx +254 -102
- package/apps/lib/components/SearchableSelect.tsx +308 -0
- package/apps/lib/components/SearchableTable.tsx +363 -0
- package/apps/lib/components/Table.tsx +6 -6
- package/dist/bin/index.js +2 -1
- package/dist/bin/index.js.map +1 -1
- package/docs/components/context-menu.md +455 -0
- package/docs/components/data-views-layout.md +16 -1
- package/docs/components/drawer.md +527 -668
- package/docs/components/dropdown-menu.md +37 -34
- package/docs/components/searchable-select.md +359 -0
- package/docs/components/searchable-table.md +419 -0
- package/docs/reference/tailwind-plugins.md +21 -1
- package/docs/tutorials/getting-started.md +15 -1
- package/package.json +1 -1
|
@@ -1,769 +1,628 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Drawer
|
|
3
|
-
description:
|
|
3
|
+
description: Gesture-based sliding panel built on Vaul. Anchors to the bottom, left, or right of the screen, supports nested/stacked drawers, and composes with SectionBlock + InputField to build create and edit forms.
|
|
4
4
|
group: Overlays & Dialogs
|
|
5
|
-
keywords: [drawer, bottom-sheet, slide
|
|
5
|
+
keywords: [drawer, sheet, side-panel, bottom-sheet, slide, vaul, form, create, edit, sectionblock, nested, drag-to-dismiss]
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Drawer
|
|
9
9
|
|
|
10
|
-
> A
|
|
10
|
+
> A gesture-based sliding panel built on [Vaul](https://vaul.emilkowal.ski/). It can slide up from the **bottom**, in from the **right**, or in from the **left**. It supports drag-to-dismiss, nested/stacked drawers with an iOS-style scale-back effect, an optional "notch" tab on the top edge, and a dark framed "tray" look. It is the recommended surface for create / edit forms, filter panels, side navigation, action sheets, and full-screen editors.
|
|
11
|
+
|
|
12
|
+
> [!IMPORTANT]
|
|
13
|
+
> The Drawer is **composed**, not configured. There is no single `direction` style switch on `DrawerContent`. You pick the anchor on the root `<Drawer direction="...">` and then pass layout classes (`wrapperClassName`, `className`, `trayClassName`) plus a few flags (`framed`, `showHandle`, `notch`, `notchSide`) to get the bottom / right / left look. The recipes below give you exact, copy-paste class strings for each direction.
|
|
11
14
|
|
|
12
15
|
## Installation
|
|
13
16
|
|
|
14
17
|
```bash
|
|
15
|
-
|
|
18
|
+
npx torch-glare@latest add Drawer
|
|
16
19
|
```
|
|
17
20
|
|
|
21
|
+
The Drawer's only third-party dependency is [`vaul`](https://www.npmjs.com/package/vaul), which the CLI installs automatically.
|
|
22
|
+
|
|
23
|
+
> [!NOTE]
|
|
24
|
+
> **Building create / edit forms?** The Drawer ships on its own. The form examples on this page also use `SectionBlock`, `InputField`, and `Button`. Install them alongside it:
|
|
25
|
+
>
|
|
26
|
+
> ```bash
|
|
27
|
+
> npx torch-glare@latest add Drawer SectionBlock InputField Button
|
|
28
|
+
> ```
|
|
29
|
+
>
|
|
30
|
+
> These are *not* auto-installed by `add Drawer`, because `Drawer.tsx` does not import them — they are only needed for the form composition pattern, not for the Drawer itself.
|
|
31
|
+
|
|
18
32
|
## Import
|
|
19
33
|
|
|
20
|
-
```
|
|
34
|
+
```tsx
|
|
21
35
|
import {
|
|
22
36
|
Drawer,
|
|
37
|
+
DrawerNested,
|
|
23
38
|
DrawerTrigger,
|
|
39
|
+
DrawerClose,
|
|
24
40
|
DrawerContent,
|
|
25
41
|
DrawerHeader,
|
|
42
|
+
DrawerHeaderTitle,
|
|
43
|
+
DrawerHeaderActions,
|
|
44
|
+
DrawerBadge,
|
|
26
45
|
DrawerFooter,
|
|
27
46
|
DrawerTitle,
|
|
28
47
|
DrawerDescription,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
import { Button } from '@torch-ui/components'
|
|
40
|
-
|
|
41
|
-
function Example() {
|
|
42
|
-
return (
|
|
43
|
-
<Drawer>
|
|
44
|
-
<DrawerTrigger asChild>
|
|
45
|
-
<Button>Open Drawer</Button>
|
|
46
|
-
</DrawerTrigger>
|
|
47
|
-
<DrawerContent>
|
|
48
|
-
<DrawerHeader>
|
|
49
|
-
<DrawerTitle>Drawer Title</DrawerTitle>
|
|
50
|
-
<DrawerDescription>
|
|
51
|
-
This is a drawer that slides up from the bottom.
|
|
52
|
-
</DrawerDescription>
|
|
53
|
-
</DrawerHeader>
|
|
54
|
-
</DrawerContent>
|
|
55
|
-
</Drawer>
|
|
56
|
-
)
|
|
57
|
-
}
|
|
48
|
+
// Notch (the tab that sticks out of the top edge)
|
|
49
|
+
DrawerNotch,
|
|
50
|
+
DrawerNotchClose,
|
|
51
|
+
DrawerNotchPill,
|
|
52
|
+
DrawerNotchDivider,
|
|
53
|
+
DrawerNotchApp,
|
|
54
|
+
// Lower-level primitives (rarely needed directly)
|
|
55
|
+
DrawerPortal,
|
|
56
|
+
DrawerOverlay,
|
|
57
|
+
} from "@/components/Drawer";
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<DrawerDescription>
|
|
82
|
-
Share your thoughts about this post.
|
|
83
|
-
</DrawerDescription>
|
|
84
|
-
</DrawerHeader>
|
|
85
|
-
|
|
86
|
-
<div className="p-4 space-y-4">
|
|
87
|
-
<Input placeholder="Your name" />
|
|
88
|
-
<textarea
|
|
89
|
-
placeholder="Your comment"
|
|
90
|
-
className="w-full p-2 border rounded"
|
|
91
|
-
rows={4}
|
|
92
|
-
/>
|
|
93
|
-
</div>
|
|
94
|
-
|
|
95
|
-
<DrawerFooter>
|
|
96
|
-
<Button type="submit">Submit</Button>
|
|
97
|
-
<DrawerClose asChild>
|
|
98
|
-
<Button variant="SecondaryStyle">Cancel</Button>
|
|
99
|
-
</DrawerClose>
|
|
100
|
-
</DrawerFooter>
|
|
101
|
-
</form>
|
|
102
|
-
</DrawerContent>
|
|
103
|
-
</Drawer>
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
### Controlled State
|
|
109
|
-
|
|
110
|
-
```typescript
|
|
111
|
-
import { Drawer, DrawerContent, DrawerTitle } from '@torch-ui/components'
|
|
112
|
-
import { useState } from 'react'
|
|
113
|
-
|
|
114
|
-
function ControlledDrawer() {
|
|
115
|
-
const [open, setOpen] = useState(false)
|
|
116
|
-
|
|
117
|
-
return (
|
|
118
|
-
<>
|
|
119
|
-
<button onClick={() => setOpen(true)}>Open Drawer</button>
|
|
120
|
-
<Drawer open={open} onOpenChange={setOpen}>
|
|
121
|
-
<DrawerContent>
|
|
122
|
-
<DrawerTitle>Controlled Drawer</DrawerTitle>
|
|
123
|
-
<button onClick={() => setOpen(false)}>Close</button>
|
|
124
|
-
</DrawerContent>
|
|
125
|
-
</Drawer>
|
|
126
|
-
</>
|
|
127
|
-
)
|
|
128
|
-
}
|
|
60
|
+
## Anatomy
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
<Drawer> {/* root — owns open state + direction */}
|
|
64
|
+
<DrawerTrigger /> {/* what opens the drawer (use asChild) */}
|
|
65
|
+
<DrawerContent> {/* the panel; controls tray/frame/handle/notch */}
|
|
66
|
+
<DrawerHeader>
|
|
67
|
+
<DrawerHeaderTitle> {/* dark pill holding the title + a badge */}
|
|
68
|
+
<DrawerBadge />
|
|
69
|
+
<DrawerTitle />
|
|
70
|
+
</DrawerHeaderTitle>
|
|
71
|
+
<DrawerHeaderActions /> {/* dark pill holding header buttons */}
|
|
72
|
+
</DrawerHeader>
|
|
73
|
+
|
|
74
|
+
{/* ...your body content... */}
|
|
75
|
+
|
|
76
|
+
<DrawerFooter>
|
|
77
|
+
<DrawerClose /> {/* anything that should close the drawer */}
|
|
78
|
+
</DrawerFooter>
|
|
79
|
+
</DrawerContent>
|
|
80
|
+
</Drawer>
|
|
129
81
|
```
|
|
130
82
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
```typescript
|
|
134
|
-
import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerClose } from '@torch-ui/components'
|
|
135
|
-
import { Button } from '@torch-ui/components'
|
|
136
|
-
|
|
137
|
-
function ActionSheet() {
|
|
138
|
-
const handleShare = (platform: string) => {
|
|
139
|
-
console.log('Sharing to', platform)
|
|
140
|
-
}
|
|
83
|
+
## Quick Examples
|
|
141
84
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<i className="ri-link mr-2" />
|
|
176
|
-
Copy Link
|
|
177
|
-
</button>
|
|
178
|
-
</DrawerClose>
|
|
179
|
-
</div>
|
|
180
|
-
</DrawerContent>
|
|
181
|
-
</Drawer>
|
|
182
|
-
)
|
|
183
|
-
}
|
|
85
|
+
### Basic (bottom sheet)
|
|
86
|
+
|
|
87
|
+
The default. Slides up from the bottom with a drag handle. Use `framed={false}` for the clean sheet look (no dark tray border).
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
<Drawer>
|
|
91
|
+
<DrawerTrigger asChild>
|
|
92
|
+
<Button variant="PrimeStyle">Open drawer</Button>
|
|
93
|
+
</DrawerTrigger>
|
|
94
|
+
<DrawerContent framed={false}>
|
|
95
|
+
<DrawerHeader>
|
|
96
|
+
<DrawerTitle>Basic drawer</DrawerTitle>
|
|
97
|
+
<DrawerDescription>
|
|
98
|
+
Drag the handle down or press Escape to close.
|
|
99
|
+
</DrawerDescription>
|
|
100
|
+
</DrawerHeader>
|
|
101
|
+
|
|
102
|
+
<div className="px-4 pb-4">
|
|
103
|
+
<p className="typography-body-small-regular text-content-presentation-action-light-secondary">
|
|
104
|
+
Put any content here — forms, lists, settings.
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<DrawerFooter>
|
|
109
|
+
<DrawerClose asChild>
|
|
110
|
+
<Button variant="PrimeStyle">Done</Button>
|
|
111
|
+
</DrawerClose>
|
|
112
|
+
<DrawerClose asChild>
|
|
113
|
+
<Button variant="BorderStyle">Cancel</Button>
|
|
114
|
+
</DrawerClose>
|
|
115
|
+
</DrawerFooter>
|
|
116
|
+
</DrawerContent>
|
|
117
|
+
</Drawer>
|
|
184
118
|
```
|
|
185
119
|
|
|
186
|
-
###
|
|
187
|
-
|
|
188
|
-
```typescript
|
|
189
|
-
import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerFooter, DrawerClose } from '@torch-ui/components'
|
|
190
|
-
import { Button, Badge } from '@torch-ui/components'
|
|
191
|
-
|
|
192
|
-
function ProductDrawer({ product }: { product: Product }) {
|
|
193
|
-
return (
|
|
194
|
-
<Drawer>
|
|
195
|
-
<DrawerTrigger asChild>
|
|
196
|
-
<button className="w-full text-left p-4 border rounded hover:bg-gray-50">
|
|
197
|
-
<h3>{product.name}</h3>
|
|
198
|
-
<p>${product.price}</p>
|
|
199
|
-
</button>
|
|
200
|
-
</DrawerTrigger>
|
|
201
|
-
<DrawerContent>
|
|
202
|
-
<DrawerHeader>
|
|
203
|
-
<DrawerTitle>{product.name}</DrawerTitle>
|
|
204
|
-
<DrawerDescription>
|
|
205
|
-
<Badge variant="green" label={product.stock > 0 ? 'In Stock' : 'Out of Stock'} />
|
|
206
|
-
</DrawerDescription>
|
|
207
|
-
</DrawerHeader>
|
|
120
|
+
### Create / Edit form (Drawer + SectionBlock)
|
|
208
121
|
|
|
209
|
-
|
|
210
|
-
<img src={product.image} alt={product.name} className="w-full rounded-lg mb-4" />
|
|
211
|
-
<p className="text-2xl font-bold mb-2">${product.price}</p>
|
|
212
|
-
<p className="text-gray-600">{product.description}</p>
|
|
213
|
-
</div>
|
|
122
|
+
This is the headline pattern: a **right-anchored** drawer that holds a form, grouped into `SectionBlock`s, with the inputs laid out as label-on-left / field-on-right rows. The same layout works for both **"New item"** (empty fields) and **"Edit item"** (pass `defaultValue` to each field and swap the badge/title).
|
|
214
123
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
import {
|
|
233
|
-
import { Button, Checkbox } from '@torch-ui/components'
|
|
234
|
-
import { useState } from 'react'
|
|
124
|
+
```tsx
|
|
125
|
+
import {
|
|
126
|
+
Drawer,
|
|
127
|
+
DrawerTrigger,
|
|
128
|
+
DrawerContent,
|
|
129
|
+
DrawerHeader,
|
|
130
|
+
DrawerHeaderTitle,
|
|
131
|
+
DrawerHeaderActions,
|
|
132
|
+
DrawerBadge,
|
|
133
|
+
DrawerTitle,
|
|
134
|
+
DrawerClose,
|
|
135
|
+
DrawerNotch,
|
|
136
|
+
DrawerNotchClose,
|
|
137
|
+
DrawerNotchPill,
|
|
138
|
+
} from "@/components/Drawer";
|
|
139
|
+
import { Button } from "@/components/Button";
|
|
140
|
+
import { SectionBlock } from "@/components/SectionBlock";
|
|
141
|
+
import { InputField } from "@/components/InputField";
|
|
235
142
|
|
|
236
|
-
function
|
|
237
|
-
const
|
|
238
|
-
inStock: false,
|
|
239
|
-
onSale: false,
|
|
240
|
-
freeShipping: false,
|
|
241
|
-
})
|
|
143
|
+
function ContactDrawer({ mode = "create", contact }) {
|
|
144
|
+
const isEdit = mode === "edit";
|
|
242
145
|
|
|
243
146
|
return (
|
|
244
|
-
<Drawer>
|
|
147
|
+
<Drawer direction="right">
|
|
245
148
|
<DrawerTrigger asChild>
|
|
246
|
-
<Button>
|
|
247
|
-
|
|
248
|
-
Filters
|
|
149
|
+
<Button variant="PrimeStyle">
|
|
150
|
+
{isEdit ? "Edit contact" : "New contact"}
|
|
249
151
|
</Button>
|
|
250
152
|
</DrawerTrigger>
|
|
251
|
-
|
|
153
|
+
|
|
154
|
+
<DrawerContent
|
|
155
|
+
showHandle={false}
|
|
156
|
+
/* The top-edge notch: close button + "Open in new tab" pill. */
|
|
157
|
+
notch={
|
|
158
|
+
<DrawerNotch>
|
|
159
|
+
<DrawerClose asChild>
|
|
160
|
+
<DrawerNotchClose />
|
|
161
|
+
</DrawerClose>
|
|
162
|
+
<DrawerNotchPill color="Yellow">
|
|
163
|
+
Open in new tab
|
|
164
|
+
<i className="ri-arrow-right-up-line text-[12px]" />
|
|
165
|
+
</DrawerNotchPill>
|
|
166
|
+
</DrawerNotch>
|
|
167
|
+
}
|
|
168
|
+
wrapperClassName="top-2 right-2 bottom-2 left-auto mt-0 h-auto w-[1046px] max-w-[calc(100vw-16px)]"
|
|
169
|
+
/* With a notch, round all corners EXCEPT the top one the notch sits on. */
|
|
170
|
+
className="rounded-tr-[16px] rounded-b-[16px]"
|
|
171
|
+
>
|
|
172
|
+
{/* Header: badge flips New / Edit, actions live in their own dark pill */}
|
|
252
173
|
<DrawerHeader>
|
|
253
|
-
<
|
|
174
|
+
<DrawerHeaderTitle>
|
|
175
|
+
<DrawerBadge color={isEdit ? "Yellow" : "Blue"}>
|
|
176
|
+
{isEdit ? "Edit" : "New"}
|
|
177
|
+
</DrawerBadge>
|
|
178
|
+
<DrawerTitle>Individual Contact</DrawerTitle>
|
|
179
|
+
</DrawerHeaderTitle>
|
|
180
|
+
<div className="flex items-center">
|
|
181
|
+
<Button variant="PrimeStyle" size="L">Save Draft</Button>
|
|
182
|
+
</div>
|
|
254
183
|
</DrawerHeader>
|
|
255
184
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
185
|
+
{/* Scrollable body holds the grouped form sections */}
|
|
186
|
+
<div className="flex-1 overflow-y-auto px-12 py-6 space-y-3">
|
|
187
|
+
<SectionBlock
|
|
188
|
+
color="Blue"
|
|
189
|
+
containerClassName="w-full"
|
|
190
|
+
title={
|
|
191
|
+
<span className="flex items-center gap-[6px]">
|
|
192
|
+
<i className="ri-draft-fill" />
|
|
193
|
+
Identity
|
|
194
|
+
</span>
|
|
195
|
+
}
|
|
196
|
+
>
|
|
197
|
+
<FieldRow
|
|
198
|
+
label="Name"
|
|
199
|
+
required
|
|
200
|
+
right={
|
|
201
|
+
<div className="flex flex-1 min-w-0 items-center gap-3">
|
|
202
|
+
<InputField
|
|
203
|
+
placeholder="First Name*"
|
|
204
|
+
defaultValue={contact?.firstName}
|
|
205
|
+
className="flex-1 min-w-0"
|
|
206
|
+
/>
|
|
207
|
+
<InputField
|
|
208
|
+
placeholder="Last Name*"
|
|
209
|
+
defaultValue={contact?.lastName}
|
|
210
|
+
className="flex-1 min-w-0"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
262
213
|
}
|
|
263
214
|
/>
|
|
264
|
-
<
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
215
|
+
<RowDivider />
|
|
216
|
+
<FieldRow
|
|
217
|
+
label="Email"
|
|
218
|
+
required
|
|
219
|
+
right={
|
|
220
|
+
<InputField
|
|
221
|
+
type="email"
|
|
222
|
+
placeholder="name@example.com"
|
|
223
|
+
defaultValue={contact?.email}
|
|
224
|
+
className="flex-1"
|
|
225
|
+
/>
|
|
271
226
|
}
|
|
272
227
|
/>
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
228
|
+
<RowDivider />
|
|
229
|
+
<FieldRow
|
|
230
|
+
label="Phone"
|
|
231
|
+
right={
|
|
232
|
+
<InputField
|
|
233
|
+
placeholder="+1 555 0000"
|
|
234
|
+
defaultValue={contact?.phone}
|
|
235
|
+
className="flex-1"
|
|
236
|
+
/>
|
|
280
237
|
}
|
|
281
238
|
/>
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
### Settings Drawer
|
|
299
|
-
|
|
300
|
-
```typescript
|
|
301
|
-
import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@torch-ui/components'
|
|
302
|
-
import { Switch } from '@torch-ui/components'
|
|
303
|
-
import { useState } from 'react'
|
|
304
|
-
|
|
305
|
-
function SettingsDrawer() {
|
|
306
|
-
const [settings, setSettings] = useState({
|
|
307
|
-
notifications: true,
|
|
308
|
-
darkMode: false,
|
|
309
|
-
autoPlay: true,
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
return (
|
|
313
|
-
<Drawer>
|
|
314
|
-
<DrawerTrigger asChild>
|
|
315
|
-
<button>
|
|
316
|
-
<i className="ri-settings-line text-2xl" />
|
|
317
|
-
</button>
|
|
318
|
-
</DrawerTrigger>
|
|
319
|
-
<DrawerContent>
|
|
320
|
-
<DrawerHeader>
|
|
321
|
-
<DrawerTitle>Settings</DrawerTitle>
|
|
322
|
-
<DrawerDescription>
|
|
323
|
-
Manage your preferences
|
|
324
|
-
</DrawerDescription>
|
|
325
|
-
</DrawerHeader>
|
|
326
|
-
|
|
327
|
-
<div className="p-4 space-y-4">
|
|
328
|
-
<div className="flex items-center justify-between">
|
|
329
|
-
<span>Notifications</span>
|
|
330
|
-
<Switch
|
|
331
|
-
checked={settings.notifications}
|
|
332
|
-
onCheckedChange={(checked) =>
|
|
333
|
-
setSettings({ ...settings, notifications: checked })
|
|
334
|
-
}
|
|
239
|
+
</SectionBlock>
|
|
240
|
+
|
|
241
|
+
<SectionBlock
|
|
242
|
+
color="Purple"
|
|
243
|
+
containerClassName="w-full"
|
|
244
|
+
title={
|
|
245
|
+
<span className="flex items-center gap-[6px]">
|
|
246
|
+
<i className="ri-map-pin-line" />
|
|
247
|
+
Address
|
|
248
|
+
</span>
|
|
249
|
+
}
|
|
250
|
+
>
|
|
251
|
+
<FieldRow
|
|
252
|
+
label="Street"
|
|
253
|
+
right={<InputField placeholder="123 Main St" className="flex-1" />}
|
|
335
254
|
/>
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
checked={settings.darkMode}
|
|
341
|
-
onCheckedChange={(checked) =>
|
|
342
|
-
setSettings({ ...settings, darkMode: checked })
|
|
343
|
-
}
|
|
344
|
-
/>
|
|
345
|
-
</div>
|
|
346
|
-
<div className="flex items-center justify-between">
|
|
347
|
-
<span>Auto-play Videos</span>
|
|
348
|
-
<Switch
|
|
349
|
-
checked={settings.autoPlay}
|
|
350
|
-
onCheckedChange={(checked) =>
|
|
351
|
-
setSettings({ ...settings, autoPlay: checked })
|
|
352
|
-
}
|
|
255
|
+
<RowDivider />
|
|
256
|
+
<FieldRow
|
|
257
|
+
label="City"
|
|
258
|
+
right={<InputField placeholder="San Francisco" className="flex-1" />}
|
|
353
259
|
/>
|
|
354
|
-
</
|
|
260
|
+
</SectionBlock>
|
|
355
261
|
</div>
|
|
356
262
|
</DrawerContent>
|
|
357
263
|
</Drawer>
|
|
358
|
-
)
|
|
264
|
+
);
|
|
359
265
|
}
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
### Confirmation Drawer
|
|
363
266
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
import { Button } from '@torch-ui/components'
|
|
367
|
-
|
|
368
|
-
function ConfirmationDrawer({ onConfirm }: { onConfirm: () => void }) {
|
|
267
|
+
/* Reusable label-on-left / field-on-right row. */
|
|
268
|
+
function FieldRow({ label, required, right }) {
|
|
369
269
|
return (
|
|
370
|
-
<
|
|
371
|
-
<
|
|
372
|
-
<
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
<
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
<Button variant="DestructiveStyle" onClick={onConfirm}>
|
|
385
|
-
Yes, Delete
|
|
386
|
-
</Button>
|
|
387
|
-
</DrawerClose>
|
|
388
|
-
<DrawerClose asChild>
|
|
389
|
-
<Button variant="SecondaryStyle">Cancel</Button>
|
|
390
|
-
</DrawerClose>
|
|
391
|
-
</DrawerFooter>
|
|
392
|
-
</DrawerContent>
|
|
393
|
-
</Drawer>
|
|
394
|
-
)
|
|
270
|
+
<div className="flex items-center gap-6 py-[18px]">
|
|
271
|
+
<div className="flex w-[180px] shrink-0 items-center gap-[6px]">
|
|
272
|
+
<span className="typography-body-medium-regular text-content-presentation-action-light-primary">
|
|
273
|
+
{label}
|
|
274
|
+
</span>
|
|
275
|
+
{required && (
|
|
276
|
+
<span className="typography-body-small-medium text-content-presentation-state-negative">
|
|
277
|
+
(Required)
|
|
278
|
+
</span>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
<div className="flex flex-1 min-w-0 items-center">{right}</div>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
395
284
|
}
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
### Scrollable Content
|
|
399
285
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
function ScrollableDrawer() {
|
|
405
|
-
const items = Array.from({ length: 50 }, (_, i) => `Item ${i + 1}`)
|
|
406
|
-
|
|
407
|
-
return (
|
|
408
|
-
<Drawer>
|
|
409
|
-
<DrawerTrigger asChild>
|
|
410
|
-
<Button>View List</Button>
|
|
411
|
-
</DrawerTrigger>
|
|
412
|
-
<DrawerContent>
|
|
413
|
-
<DrawerHeader>
|
|
414
|
-
<DrawerTitle>Long List</DrawerTitle>
|
|
415
|
-
</DrawerHeader>
|
|
416
|
-
|
|
417
|
-
<ScrollArea className="h-96">
|
|
418
|
-
<div className="p-4 space-y-2">
|
|
419
|
-
{items.map((item) => (
|
|
420
|
-
<div key={item} className="p-2 border-b">
|
|
421
|
-
{item}
|
|
422
|
-
</div>
|
|
423
|
-
))}
|
|
424
|
-
</div>
|
|
425
|
-
</ScrollArea>
|
|
426
|
-
|
|
427
|
-
<DrawerFooter>
|
|
428
|
-
<Button>Done</Button>
|
|
429
|
-
</DrawerFooter>
|
|
430
|
-
</DrawerContent>
|
|
431
|
-
</Drawer>
|
|
432
|
-
)
|
|
286
|
+
/* Thin separator between rows inside a SectionBlock. */
|
|
287
|
+
function RowDivider() {
|
|
288
|
+
return <div className="h-px w-full bg-border-presentation-global-primary" />;
|
|
433
289
|
}
|
|
434
290
|
```
|
|
435
291
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
292
|
+
> **Create vs. Edit in one component.** Keep a single drawer and branch on a `mode` prop:
|
|
293
|
+
> - **Badge / title** — `<DrawerBadge color={isEdit ? "Yellow" : "Blue"}>{isEdit ? "Edit" : "New"}</DrawerBadge>`.
|
|
294
|
+
> - **Fields** — spread the existing record into each input's `defaultValue` (or `value` if controlled). Empty in create mode, pre-filled in edit mode.
|
|
295
|
+
> - **Footer / actions** — keep "Save Draft" / "Save" identical; only the submit handler changes (POST vs. PATCH).
|
|
296
|
+
|
|
297
|
+
### Nested drawers (multi-step)
|
|
298
|
+
|
|
299
|
+
`DrawerNested` must live **inside** an already-open `DrawerContent`. The parent scales down and slides back; the child slides in on top. Great for `cart → shipping → payment` style flows or drill-down settings.
|
|
300
|
+
|
|
301
|
+
```tsx
|
|
302
|
+
<Drawer>
|
|
303
|
+
<DrawerTrigger asChild>
|
|
304
|
+
<Button variant="PrimeStyle">Checkout</Button>
|
|
305
|
+
</DrawerTrigger>
|
|
306
|
+
<DrawerContent framed={false}>
|
|
307
|
+
<DrawerHeader>
|
|
308
|
+
<DrawerTitle>Your cart</DrawerTitle>
|
|
309
|
+
<DrawerDescription>1 item — $129.00</DrawerDescription>
|
|
310
|
+
</DrawerHeader>
|
|
311
|
+
|
|
312
|
+
<DrawerFooter>
|
|
313
|
+
<DrawerNested>
|
|
314
|
+
<DrawerTrigger asChild>
|
|
315
|
+
<Button variant="PrimeStyle">Continue to shipping</Button>
|
|
316
|
+
</DrawerTrigger>
|
|
317
|
+
<DrawerContent framed={false}>
|
|
318
|
+
<DrawerHeader>
|
|
319
|
+
<DrawerTitle>Shipping</DrawerTitle>
|
|
320
|
+
</DrawerHeader>
|
|
321
|
+
{/* ...another DrawerNested inside here for payment... */}
|
|
322
|
+
</DrawerContent>
|
|
323
|
+
</DrawerNested>
|
|
324
|
+
</DrawerFooter>
|
|
325
|
+
</DrawerContent>
|
|
326
|
+
</Drawer>
|
|
454
327
|
```
|
|
455
328
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
329
|
+
### Notch (top-edge tab)
|
|
330
|
+
|
|
331
|
+
The notch is an optional tab that sticks out of the top edge of the drawer — used for a close button plus an "Open in new tab" / "Open in the app" affordance. Pass it to `DrawerContent` via the `notch` prop. When a notch is present, hide the drag handle (`showHandle={false}`) and use the asymmetric corner rounding (`className="rounded-tr-[16px] rounded-b-[16px]"` for a right drawer) so the panel tucks under the notch.
|
|
332
|
+
|
|
333
|
+
**Simple notch** — close button + "Open in new tab" pill. This is the most common form, used on the create/edit drawer above:
|
|
334
|
+
|
|
335
|
+
```tsx
|
|
336
|
+
<DrawerContent
|
|
337
|
+
showHandle={false}
|
|
338
|
+
className="rounded-tr-[16px] rounded-b-[16px]"
|
|
339
|
+
notch={
|
|
340
|
+
<DrawerNotch>
|
|
341
|
+
<DrawerClose asChild>
|
|
342
|
+
<DrawerNotchClose />
|
|
343
|
+
</DrawerClose>
|
|
344
|
+
<DrawerNotchPill color="Yellow">
|
|
345
|
+
Open in new tab
|
|
346
|
+
<i className="ri-arrow-right-up-line text-[12px]" />
|
|
347
|
+
</DrawerNotchPill>
|
|
348
|
+
</DrawerNotch>
|
|
349
|
+
}
|
|
350
|
+
>
|
|
351
|
+
{/* ... */}
|
|
352
|
+
</DrawerContent>
|
|
353
|
+
```
|
|
473
354
|
|
|
474
|
-
|
|
355
|
+
**App notch** — adds an app icon + name and a colored "Open in the app" pill, separated by a divider:
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
<DrawerContent
|
|
359
|
+
showHandle={false}
|
|
360
|
+
className="rounded-tr-[16px] rounded-b-[16px]"
|
|
361
|
+
notch={
|
|
362
|
+
<DrawerNotch>
|
|
363
|
+
<DrawerClose asChild>
|
|
364
|
+
<DrawerNotchClose />
|
|
365
|
+
</DrawerClose>
|
|
366
|
+
<DrawerNotchDivider />
|
|
367
|
+
<DrawerNotchApp
|
|
368
|
+
icon={<i className="ri-customer-service-2-fill text-white text-[14px]" />}
|
|
369
|
+
name="Sales & Services App"
|
|
370
|
+
/>
|
|
371
|
+
<DrawerNotchPill color="Blue">
|
|
372
|
+
Open in the app
|
|
373
|
+
<i className="ri-arrow-right-up-line text-[12px]" />
|
|
374
|
+
</DrawerNotchPill>
|
|
375
|
+
</DrawerNotch>
|
|
376
|
+
}
|
|
377
|
+
>
|
|
378
|
+
{/* ... */}
|
|
379
|
+
</DrawerContent>
|
|
380
|
+
```
|
|
475
381
|
|
|
476
|
-
|
|
477
|
-
|------|------|---------|-------------|
|
|
478
|
-
| `className` | `string` | - | Additional CSS classes |
|
|
479
|
-
| `onPointerDownOutside` | `(event) => void` | - | Callback when clicking outside |
|
|
480
|
-
| `onEscapeKeyDown` | `(event) => void` | - | Callback when Escape is pressed |
|
|
382
|
+
#### What "Open in new tab" does
|
|
481
383
|
|
|
482
|
-
|
|
384
|
+
> [!IMPORTANT]
|
|
385
|
+
> **`DrawerNotchPill` has no built-in navigation — it is a styled `<button>`.** The "Open in new tab" interaction is a pattern *you* wire up. The intended behavior is:
|
|
386
|
+
>
|
|
387
|
+
> **Clicking "Open in new tab" opens the *same content* the drawer is showing, but as a standalone full page (its own route) in a new browser tab — not as a drawer.**
|
|
388
|
+
>
|
|
389
|
+
> The drawer is the *quick/inline* way to view or edit a record; the full page is the *expanded* view of the exact same thing (same form, same data), just rendered at full width instead of in the side panel. So a "New contact" drawer opens a full-page "New contact" form; an "Edit contact #42" drawer opens the full-page edit route for contact 42.
|
|
483
390
|
|
|
484
|
-
|
|
485
|
-
|------|------|---------|-------------|
|
|
486
|
-
| `className` | `string` | - | Additional CSS classes |
|
|
391
|
+
**How to wire it.** Point the pill at the route that renders the same section as a page, and open it in a new tab. `DrawerNotchPill` forwards all button props, so use `onClick`:
|
|
487
392
|
|
|
488
|
-
|
|
393
|
+
```tsx
|
|
394
|
+
// `href` is the full-page route that renders the SAME section/form as the drawer.
|
|
395
|
+
// For an edit drawer, include the record id so the page opens pre-filled:
|
|
396
|
+
// const href = isEdit ? `/contacts/${contact.id}/edit` : "/contacts/new";
|
|
489
397
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
398
|
+
<DrawerNotchPill
|
|
399
|
+
color="Yellow"
|
|
400
|
+
onClick={() => window.open(href, "_blank", "noopener")}
|
|
401
|
+
>
|
|
402
|
+
Open in new tab
|
|
403
|
+
<i className="ri-arrow-right-up-line text-[12px]" />
|
|
404
|
+
</DrawerNotchPill>
|
|
405
|
+
```
|
|
495
406
|
|
|
496
|
-
|
|
497
|
-
|------|------|---------|-------------|
|
|
498
|
-
| `className` | `string` | - | Additional CSS classes |
|
|
407
|
+
Or, if you prefer a real anchor (better for middle-click / "copy link"), wrap a styled link instead of the pill — the pill is just a button, so for true link semantics render your own `<a target="_blank">` with the same classes, or in Next.js:
|
|
499
408
|
|
|
500
|
-
|
|
409
|
+
```tsx
|
|
410
|
+
import Link from "next/link";
|
|
501
411
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
412
|
+
<Link href={href} target="_blank" rel="noopener" className="contents">
|
|
413
|
+
<DrawerNotchPill color="Yellow" tabIndex={-1}>
|
|
414
|
+
Open in new tab
|
|
415
|
+
<i className="ri-arrow-right-up-line text-[12px]" />
|
|
416
|
+
</DrawerNotchPill>
|
|
417
|
+
</Link>
|
|
418
|
+
```
|
|
505
419
|
|
|
506
|
-
|
|
420
|
+
**The contract:** the drawer and the full page must render the **same section component** with the **same data**. Build the form body (the `SectionBlock` groups + fields) as a shared component, drop it into the `DrawerContent` for the inline view and into a normal page route for the full-page view. That guarantees "open in new tab" shows an identical form — just full-page instead of in the drawer.
|
|
507
421
|
|
|
508
|
-
|
|
422
|
+
> The **"Open in the app"** pill (App notch) follows the same idea but targets a different surface — e.g. a deep link into a separate application — rather than a full-page route in the same app.
|
|
509
423
|
|
|
510
|
-
|
|
511
|
-
import { Drawer as DrawerPrimitive } from 'vaul'
|
|
424
|
+
## Direction — how bottom, right, and left differ
|
|
512
425
|
|
|
513
|
-
|
|
514
|
-
interface DrawerProps extends React.ComponentProps<typeof DrawerPrimitive.Root> {
|
|
515
|
-
shouldScaleBackground?: boolean
|
|
516
|
-
open?: boolean
|
|
517
|
-
onOpenChange?: (open: boolean) => void
|
|
518
|
-
defaultOpen?: boolean
|
|
519
|
-
modal?: boolean
|
|
520
|
-
children: React.ReactNode
|
|
521
|
-
}
|
|
426
|
+
There is **no automatic per-direction styling**. The anchor is set on the root with `direction`, and the *look* (where it sits, which corners round, whether there's a drag handle or a frame) comes from props you pass to `DrawerContent`. Here is exactly what changes per direction and the copy-paste recipe for each.
|
|
522
427
|
|
|
523
|
-
|
|
428
|
+
| Aspect | Bottom (default) | Right | Left |
|
|
429
|
+
|---|---|---|---|
|
|
430
|
+
| Root prop | _(none)_ | `direction="right"` | `direction="left"` |
|
|
431
|
+
| Sits at | full width, pinned to bottom | floating panel on the right | floating panel on the left |
|
|
432
|
+
| Drag handle | **shown** (`showHandle` default `true`) | hidden (`showHandle={false}`) | hidden (`showHandle={false}`) |
|
|
433
|
+
| Frame / tray | usually off (`framed={false}`) for clean sheet | on (default) for the dark tray | on (default) |
|
|
434
|
+
| Rounded corners | top corners only | all/left corners | top-left + bottom corners |
|
|
435
|
+
| Notch side | top-left (`notchSide="left"`) | top-left | mirror to `notchSide="right"` |
|
|
436
|
+
| Best for | mobile sheets, action sheets, comments | create/edit forms, filters, detail panels | RTL panels, side navigation |
|
|
524
437
|
|
|
525
|
-
|
|
526
|
-
export const DrawerTrigger: React.ForwardRefExoticComponent<
|
|
527
|
-
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Trigger>
|
|
528
|
-
>
|
|
438
|
+
### Bottom (default)
|
|
529
439
|
|
|
530
|
-
|
|
531
|
-
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
|
532
|
-
>
|
|
440
|
+
No `direction` prop. Keep the drag handle; drop the frame for a clean sheet.
|
|
533
441
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
>
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
>
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
442
|
+
```tsx
|
|
443
|
+
<Drawer>
|
|
444
|
+
<DrawerTrigger asChild>
|
|
445
|
+
<Button variant="PrimeStyle">Open bottom sheet</Button>
|
|
446
|
+
</DrawerTrigger>
|
|
447
|
+
<DrawerContent framed={false}>
|
|
448
|
+
{/* drag handle appears automatically */}
|
|
449
|
+
<DrawerHeader>
|
|
450
|
+
<DrawerTitle>Comments</DrawerTitle>
|
|
451
|
+
</DrawerHeader>
|
|
452
|
+
{/* ... */}
|
|
453
|
+
</DrawerContent>
|
|
454
|
+
</Drawer>
|
|
545
455
|
```
|
|
546
456
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
### useDrawer Hook
|
|
550
|
-
|
|
551
|
-
```typescript
|
|
552
|
-
import { useState } from 'react'
|
|
553
|
-
|
|
554
|
-
function useDrawer() {
|
|
555
|
-
const [open, setOpen] = useState(false)
|
|
457
|
+
**Half-screen / snap points** — the bottom drawer can open to half height then snap to full. Drive it with Vaul's `snapPoints` on the root:
|
|
556
458
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
toggleDrawer,
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Usage
|
|
571
|
-
function App() {
|
|
572
|
-
const drawer = useDrawer()
|
|
573
|
-
|
|
574
|
-
return (
|
|
575
|
-
<>
|
|
576
|
-
<button onClick={drawer.openDrawer}>Open</button>
|
|
577
|
-
<Drawer open={drawer.open} onOpenChange={drawer.setOpen}>
|
|
578
|
-
<DrawerContent>Content</DrawerContent>
|
|
579
|
-
</Drawer>
|
|
580
|
-
</>
|
|
581
|
-
)
|
|
582
|
-
}
|
|
459
|
+
```tsx
|
|
460
|
+
<Drawer snapPoints={[0.5, 1]} fadeFromIndex={1}>
|
|
461
|
+
<DrawerTrigger asChild>
|
|
462
|
+
<Button variant="PrimeStyle">Open half-screen</Button>
|
|
463
|
+
</DrawerTrigger>
|
|
464
|
+
<DrawerContent framed={false} wrapperClassName="h-full max-h-[97vh]">
|
|
465
|
+
{/* ... */}
|
|
466
|
+
</DrawerContent>
|
|
467
|
+
</Drawer>
|
|
583
468
|
```
|
|
584
469
|
|
|
585
|
-
|
|
470
|
+
### Right
|
|
471
|
+
|
|
472
|
+
A floating panel anchored to the right edge — the canonical home for create/edit forms and filter panels. Hide the handle, anchor with `wrapperClassName`, round the corners.
|
|
473
|
+
|
|
474
|
+
```tsx
|
|
475
|
+
<Drawer direction="right">
|
|
476
|
+
<DrawerTrigger asChild>
|
|
477
|
+
<Button variant="PrimeStyle">Open right drawer</Button>
|
|
478
|
+
</DrawerTrigger>
|
|
479
|
+
<DrawerContent
|
|
480
|
+
showHandle={false}
|
|
481
|
+
wrapperClassName="top-2 right-2 bottom-2 left-auto mt-0 h-auto w-[420px] max-w-[calc(100vw-16px)]"
|
|
482
|
+
trayClassName="rounded-[16px]"
|
|
483
|
+
className="rounded-[10px]"
|
|
484
|
+
>
|
|
485
|
+
<DrawerHeader>
|
|
486
|
+
<DrawerTitle>Filters</DrawerTitle>
|
|
487
|
+
<DrawerDescription>Status, owner, tags, priority…</DrawerDescription>
|
|
488
|
+
</DrawerHeader>
|
|
489
|
+
<div className="px-4 pb-4 space-y-3 flex-1 overflow-y-auto">
|
|
490
|
+
{/* ...filter rows... */}
|
|
491
|
+
</div>
|
|
492
|
+
<DrawerFooter>
|
|
493
|
+
<DrawerClose asChild>
|
|
494
|
+
<Button variant="PrimeStyle">Apply</Button>
|
|
495
|
+
</DrawerClose>
|
|
496
|
+
<DrawerClose asChild>
|
|
497
|
+
<Button variant="BorderStyle">Reset</Button>
|
|
498
|
+
</DrawerClose>
|
|
499
|
+
</DrawerFooter>
|
|
500
|
+
</DrawerContent>
|
|
501
|
+
</Drawer>
|
|
502
|
+
```
|
|
586
503
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
504
|
+
> Widen `w-[420px]` to `w-[1046px]` (as in the form example) for a roomy two-column form. Always pair a fixed width with `max-w-[calc(100vw-16px)]` so it never overflows on small screens.
|
|
505
|
+
|
|
506
|
+
### Left (RTL / navigation)
|
|
507
|
+
|
|
508
|
+
Mirror of the right recipe: `direction="left"`, anchor to the left edge, and if you use a notch, set `notchSide="right"` so the tab mirrors correctly.
|
|
509
|
+
|
|
510
|
+
```tsx
|
|
511
|
+
<Drawer direction="left">
|
|
512
|
+
<DrawerTrigger asChild>
|
|
513
|
+
<Button variant="PrimeStyle">Open left drawer</Button>
|
|
514
|
+
</DrawerTrigger>
|
|
515
|
+
<DrawerContent
|
|
516
|
+
showHandle={false}
|
|
517
|
+
notchSide="right"
|
|
518
|
+
wrapperClassName="top-2 left-2 bottom-2 right-auto mt-0 h-auto w-[420px] max-w-[calc(100vw-16px)]"
|
|
519
|
+
className="rounded-tl-[16px] rounded-b-[16px]"
|
|
520
|
+
notch={
|
|
521
|
+
<DrawerNotch>
|
|
522
|
+
<DrawerClose asChild>
|
|
523
|
+
<DrawerNotchClose />
|
|
524
|
+
</DrawerClose>
|
|
525
|
+
<DrawerNotchPill color="Yellow">
|
|
526
|
+
Open in new tab
|
|
527
|
+
<i className="ri-arrow-right-up-line text-[12px]" />
|
|
528
|
+
</DrawerNotchPill>
|
|
529
|
+
</DrawerNotch>
|
|
530
|
+
}
|
|
531
|
+
>
|
|
532
|
+
<DrawerHeader>
|
|
533
|
+
<DrawerHeaderTitle>
|
|
534
|
+
<DrawerBadge color="Purple">Menu</DrawerBadge>
|
|
535
|
+
<DrawerTitle>Navigation</DrawerTitle>
|
|
536
|
+
</DrawerHeaderTitle>
|
|
537
|
+
</DrawerHeader>
|
|
538
|
+
{/* ...nav items... */}
|
|
539
|
+
</DrawerContent>
|
|
540
|
+
</Drawer>
|
|
541
|
+
```
|
|
591
542
|
|
|
592
|
-
###
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
543
|
+
### Full-screen
|
|
544
|
+
|
|
545
|
+
Anchor a bottom drawer to every edge via `wrapperClassName`.
|
|
546
|
+
|
|
547
|
+
```tsx
|
|
548
|
+
<Drawer>
|
|
549
|
+
<DrawerTrigger asChild>
|
|
550
|
+
<Button variant="PrimeStyle">Open full-screen</Button>
|
|
551
|
+
</DrawerTrigger>
|
|
552
|
+
<DrawerContent
|
|
553
|
+
framed={false}
|
|
554
|
+
wrapperClassName="inset-x-0 top-0 bottom-0 m-0 h-screen w-screen max-w-none"
|
|
555
|
+
>
|
|
556
|
+
{/* immersive editor / media viewer */}
|
|
557
|
+
</DrawerContent>
|
|
597
558
|
</Drawer>
|
|
598
559
|
```
|
|
599
560
|
|
|
600
|
-
##
|
|
561
|
+
## API Reference
|
|
601
562
|
|
|
602
|
-
###
|
|
563
|
+
### `Drawer` (root)
|
|
603
564
|
|
|
604
|
-
|
|
605
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
606
|
-
import { Drawer, DrawerTrigger, DrawerContent, DrawerTitle } from '@torch-ui/components'
|
|
565
|
+
Wraps Vaul's `Drawer.Root` and defaults `shouldScaleBackground` to `true`. Accepts all [Vaul Root props](https://vaul.emilkowal.ski/api).
|
|
607
566
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
)
|
|
618
|
-
|
|
619
|
-
fireEvent.click(screen.getByText('Open'))
|
|
620
|
-
expect(screen.getByText('Test Drawer')).toBeInTheDocument()
|
|
621
|
-
})
|
|
622
|
-
|
|
623
|
-
it('closes on close button click', () => {
|
|
624
|
-
render(
|
|
625
|
-
<Drawer defaultOpen>
|
|
626
|
-
<DrawerContent>
|
|
627
|
-
<DrawerTitle>Test</DrawerTitle>
|
|
628
|
-
<DrawerClose>Close</DrawerClose>
|
|
629
|
-
</DrawerContent>
|
|
630
|
-
</Drawer>
|
|
631
|
-
)
|
|
632
|
-
|
|
633
|
-
fireEvent.click(screen.getByText('Close'))
|
|
634
|
-
expect(screen.queryByText('Test')).not.toBeInTheDocument()
|
|
635
|
-
})
|
|
636
|
-
|
|
637
|
-
it('handles controlled state', () => {
|
|
638
|
-
const handleChange = jest.fn()
|
|
639
|
-
|
|
640
|
-
render(
|
|
641
|
-
<Drawer onOpenChange={handleChange}>
|
|
642
|
-
<DrawerTrigger>Open</DrawerTrigger>
|
|
643
|
-
<DrawerContent>Content</DrawerContent>
|
|
644
|
-
</Drawer>
|
|
645
|
-
)
|
|
646
|
-
|
|
647
|
-
fireEvent.click(screen.getByText('Open'))
|
|
648
|
-
expect(handleChange).toHaveBeenCalledWith(true)
|
|
649
|
-
})
|
|
650
|
-
})
|
|
651
|
-
```
|
|
567
|
+
| Prop | Type | Default | Description |
|
|
568
|
+
|---|---|---|---|
|
|
569
|
+
| `direction` | `"bottom" \| "top" \| "left" \| "right"` | `"bottom"` | Edge the drawer slides in from. |
|
|
570
|
+
| `shouldScaleBackground` | `boolean` | `true` | Scales/pushes the page back when open (the iOS sheet effect). |
|
|
571
|
+
| `open` | `boolean` | — | Controlled open state. |
|
|
572
|
+
| `onOpenChange` | `(open: boolean) => void` | — | Fires when open state changes. |
|
|
573
|
+
| `snapPoints` | `(number \| string)[]` | — | Snap stops, e.g. `[0.5, 1]` for half then full. |
|
|
574
|
+
| `fadeFromIndex` | `number` | — | Snap index from which the overlay starts to fade. |
|
|
575
|
+
| `nested` | `boolean` | — | Prefer the `DrawerNested` component instead. |
|
|
652
576
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
- **Keyboard Support**:
|
|
656
|
-
- Escape: Close drawer
|
|
657
|
-
- Tab: Navigate focusable elements
|
|
658
|
-
- Shift+Tab: Navigate backwards
|
|
659
|
-
- **ARIA Attributes**:
|
|
660
|
-
- `role="dialog"` automatically applied
|
|
661
|
-
- `aria-labelledby` links to title
|
|
662
|
-
- `aria-describedby` links to description
|
|
663
|
-
- **Focus Management**:
|
|
664
|
-
- Focus trapped within drawer when open
|
|
665
|
-
- Focus returned to trigger on close
|
|
666
|
-
- **Touch/Pointer**: Gesture-based closing with drag down
|
|
667
|
-
|
|
668
|
-
### Accessibility Best Practices
|
|
669
|
-
|
|
670
|
-
```typescript
|
|
671
|
-
// Always provide a title
|
|
672
|
-
<DrawerContent>
|
|
673
|
-
<DrawerTitle>Drawer Title</DrawerTitle> {/* Required */}
|
|
674
|
-
<DrawerDescription>Description</DrawerDescription>
|
|
675
|
-
</DrawerContent>
|
|
577
|
+
### `DrawerContent`
|
|
676
578
|
|
|
677
|
-
|
|
678
|
-
<DrawerClose asChild>
|
|
679
|
-
<Button aria-label="Close drawer">Close</Button>
|
|
680
|
-
</DrawerClose>
|
|
681
|
-
```
|
|
579
|
+
The panel. This is where direction-specific styling is applied.
|
|
682
580
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
|
686
|
-
|
|
687
|
-
|
|
|
688
|
-
|
|
|
689
|
-
|
|
|
690
|
-
|
|
|
691
|
-
|
|
|
692
|
-
|
|
|
693
|
-
|
|
694
|
-
###
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
-
|
|
714
|
-
- <DialogTrigger>Open</DialogTrigger>
|
|
715
|
-
- <DialogContent>Content</DialogContent>
|
|
716
|
-
- </Dialog>
|
|
717
|
-
|
|
718
|
-
+ <Drawer>
|
|
719
|
-
+ <DrawerTrigger>Open</DrawerTrigger>
|
|
720
|
-
+ <DrawerContent>Content</DrawerContent>
|
|
721
|
-
+ </Drawer>
|
|
722
|
-
```
|
|
581
|
+
| Prop | Type | Default | Description |
|
|
582
|
+
|---|---|---|---|
|
|
583
|
+
| `framed` | `boolean` | `true` | Show the dark "tray" frame (border + inset shadow) around the panel. Set `false` for clean bottom sheets. |
|
|
584
|
+
| `showHandle` | `boolean` | `true` | Show the centered drag handle. Auto-hidden when a `notch` is present. Set `false` for side drawers. |
|
|
585
|
+
| `notch` | `ReactNode` | — | A `DrawerNotch` tab rendered on the top edge. |
|
|
586
|
+
| `notchSide` | `"left" \| "right"` | `"left"` | Which side the notch attaches to (and which corner stays square). Use `"right"` for left-anchored drawers. |
|
|
587
|
+
| `wrapperClassName` | `string` | — | Classes on the outer positioned element — this is how you anchor/size the panel per direction. |
|
|
588
|
+
| `trayClassName` | `string` | — | Classes on the dark tray frame (e.g. corner rounding). |
|
|
589
|
+
| `className` | `string` | — | Classes on the inner light content surface. |
|
|
590
|
+
| `...props` | Vaul `Content` props | — | Forwarded to `Drawer.Content`. |
|
|
591
|
+
|
|
592
|
+
### Sub-components
|
|
593
|
+
|
|
594
|
+
| Component | Description |
|
|
595
|
+
|---|---|
|
|
596
|
+
| `DrawerTrigger` | Opens the drawer. Use `asChild` to wrap your own button. |
|
|
597
|
+
| `DrawerClose` | Closes the drawer. Use `asChild` to wrap any element. |
|
|
598
|
+
| `DrawerNested` | A nested/stacked drawer. Must be rendered inside an open `DrawerContent`. |
|
|
599
|
+
| `DrawerHeader` | Header row (space-between layout) holding title + actions. |
|
|
600
|
+
| `DrawerHeaderTitle` | Dark rounded pill that groups a `DrawerBadge` + `DrawerTitle`. |
|
|
601
|
+
| `DrawerHeaderActions` | Dark rounded pill (right-aligned) for header buttons. |
|
|
602
|
+
| `DrawerTitle` | Accessible title (maps to Vaul `Drawer.Title`). |
|
|
603
|
+
| `DrawerDescription` | Muted supporting text (maps to Vaul `Drawer.Description`). |
|
|
604
|
+
| `DrawerBadge` | Small uppercase status pill. `color`: `Blue \| Green \| Red \| Yellow \| Purple \| Gray` (default `Blue`). |
|
|
605
|
+
| `DrawerFooter` | Bottom action area (`mt-auto`, stacked). |
|
|
606
|
+
| `DrawerNotch` | The top-edge tab container. `side`: `"left" \| "right"`. |
|
|
607
|
+
| `DrawerNotchClose` | Round close button for inside a notch. |
|
|
608
|
+
| `DrawerNotchPill` | Pill button for inside a notch. `color`: `Yellow \| Blue \| Gray` (default `Yellow`). Styled `<button>` only — wire navigation yourself via `onClick` (see ["What 'Open in new tab' does"](#what-open-in-new-tab-does)). |
|
|
609
|
+
| `DrawerNotchDivider` | Thin vertical divider between notch items. |
|
|
610
|
+
| `DrawerNotchApp` | App identity block (icon + name) for inside a notch. Props: `icon?`, `name`. |
|
|
611
|
+
| `DrawerPortal` / `DrawerOverlay` | Lower-level primitives; rarely used directly. |
|
|
723
612
|
|
|
724
613
|
## Best Practices
|
|
725
614
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
```typescript
|
|
734
|
-
<DrawerContent className="max-h-[80vh]">
|
|
735
|
-
```
|
|
736
|
-
|
|
737
|
-
3. **Provide visual drag handle**: Built-in drag indicator included
|
|
738
|
-
```typescript
|
|
739
|
-
// Automatic drag handle bar rendered at top
|
|
740
|
-
```
|
|
741
|
-
|
|
742
|
-
4. **Use for actions and forms**: Perfect for quick interactions
|
|
743
|
-
```typescript
|
|
744
|
-
// Good: Share sheet, filters, settings
|
|
745
|
-
// Avoid: Long articles, complex wizards
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
5. **Handle background scroll**: Automatically prevents scroll
|
|
749
|
-
```typescript
|
|
750
|
-
// Background scroll blocked when drawer is open
|
|
751
|
-
```
|
|
752
|
-
|
|
753
|
-
6. **Test gesture interactions**: Ensure drag-to-close works smoothly
|
|
754
|
-
```typescript
|
|
755
|
-
// Test on actual touch devices
|
|
756
|
-
```
|
|
757
|
-
|
|
758
|
-
7. **Provide close button**: Don't rely solely on gesture
|
|
759
|
-
```typescript
|
|
760
|
-
<DrawerClose asChild>
|
|
761
|
-
<Button>Done</Button>
|
|
762
|
-
</DrawerClose>
|
|
763
|
-
```
|
|
615
|
+
- **Pick the anchor by job.** Bottom for mobile sheets / action sheets / comments; right for create-edit forms and filter/detail panels; left for RTL panels and side navigation.
|
|
616
|
+
- **Side drawers: hide the handle.** Always pass `showHandle={false}` on left/right drawers — the drag handle only makes sense on a bottom sheet.
|
|
617
|
+
- **Always cap the width.** Pair any fixed `w-[…]` with `max-w-[calc(100vw-16px)]` so the panel never overflows narrow viewports.
|
|
618
|
+
- **One drawer for create *and* edit.** Branch on a `mode` prop for the badge/title and feed `defaultValue`/`value` from the record. Don't build two near-identical drawers.
|
|
619
|
+
- **Group form fields with `SectionBlock`.** Use a colored `SectionBlock` per logical group (Identity, Address, …) and the `FieldRow` / `RowDivider` helpers for consistent label/field rows.
|
|
620
|
+
- **Make the body scroll, not the panel.** Put `flex-1 overflow-y-auto` on the body wrapper so the header/footer stay pinned while content scrolls.
|
|
621
|
+
- **`DrawerNested` only inside an open drawer.** It will not work as a standalone trigger.
|
|
764
622
|
|
|
765
623
|
## Related Components
|
|
766
624
|
|
|
767
|
-
- [Dialog](./dialog.md)
|
|
768
|
-
- [AlertDialog](./alert-dialog.md)
|
|
769
|
-
- [
|
|
625
|
+
- **[Dialog](./dialog.md)** — centered modal for short confirmations and compact forms.
|
|
626
|
+
- **[AlertDialog](./alert-dialog.md)** — blocking confirmation for destructive actions.
|
|
627
|
+
- **[SectionBlock](./section-block.md)** — the grouping container used for the form body above.
|
|
628
|
+
- **[InputField](./input-field.md)** — the input used in the form rows.
|