@duro-app/ui 0.18.0 → 0.19.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/dist/components/Checkbox/Checkbox.meta.d.ts.map +1 -1
- package/dist/components/Cluster/Cluster.meta.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts +58 -0
- package/dist/components/Dialog/Dialog.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.stories.d.ts +13 -0
- package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -0
- package/dist/components/Dialog/styles.css.d.ts +140 -0
- package/dist/components/Dialog/styles.css.d.ts.map +1 -0
- package/dist/components/Drawer/Drawer.d.ts +62 -0
- package/dist/components/Drawer/Drawer.d.ts.map +1 -0
- package/dist/components/Drawer/Drawer.stories.d.ts +15 -0
- package/dist/components/Drawer/Drawer.stories.d.ts.map +1 -0
- package/dist/components/Drawer/styles.css.d.ts +184 -0
- package/dist/components/Drawer/styles.css.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +17 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -0
- package/dist/components/Field/Field.meta.d.ts.map +1 -1
- package/dist/components/Fieldset/Fieldset.meta.d.ts.map +1 -1
- package/dist/components/Form/Form.meta.d.ts.map +1 -1
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
- package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
- package/dist/components/Input/Input.meta.d.ts.map +1 -1
- package/dist/components/LinkButton/LinkButton.meta.d.ts.map +1 -1
- package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
- package/dist/components/Stack/Stack.meta.d.ts.map +1 -1
- package/dist/components/Switch/Switch.meta.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +1 -0
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2848 -1952
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Checkbox/Checkbox.meta.ts +4 -1
- package/src/components/Cluster/Cluster.meta.ts +2 -6
- package/src/components/Dialog/Dialog.stories.tsx +325 -0
- package/src/components/Dialog/Dialog.tsx +310 -0
- package/src/components/Dialog/styles.css.ts +197 -0
- package/src/components/Drawer/Drawer.stories.tsx +364 -0
- package/src/components/Drawer/Drawer.tsx +370 -0
- package/src/components/Drawer/styles.css.ts +242 -0
- package/src/components/Drawer/useSwipeDismiss.ts +295 -0
- package/src/components/Field/Field.meta.ts +1 -3
- package/src/components/Fieldset/Fieldset.meta.ts +1 -3
- package/src/components/Form/Form.meta.ts +1 -4
- package/src/components/Grid/Grid.meta.ts +1 -3
- package/src/components/Inline/Inline.meta.ts +1 -2
- package/src/components/Input/Input.meta.ts +2 -7
- package/src/components/LinkButton/LinkButton.meta.ts +1 -3
- package/src/components/Menu/Menu.meta.ts +1 -4
- package/src/components/SideNav/SideNav.meta.ts +1 -4
- package/src/components/Stack/Stack.meta.ts +1 -2
- package/src/components/Switch/Switch.meta.ts +4 -1
- package/src/components/Table/Table.stories.tsx +107 -0
- package/src/components/Table/Table.tsx +0 -1
- package/src/components/Textarea/Textarea.meta.ts +2 -6
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@duro-app/tokens": "^0.
|
|
55
|
+
"@duro-app/tokens": "^0.19.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -12,7 +12,10 @@ export const meta: ComponentMeta = {
|
|
|
12
12
|
'Selecting one of several options — use Select or ToggleGroup',
|
|
13
13
|
],
|
|
14
14
|
relatedTo: [
|
|
15
|
-
{
|
|
15
|
+
{
|
|
16
|
+
component: 'Switch',
|
|
17
|
+
relationship: 'Switch for on/off settings; Checkbox for opt-in/selection',
|
|
18
|
+
},
|
|
16
19
|
{component: 'Toggle', relationship: 'Toggle for toolbar actions; Checkbox for form input'},
|
|
17
20
|
],
|
|
18
21
|
example: `<Checkbox name="terms" onChange={(e) => console.log(e.target.checked)}>
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import type {ComponentMeta} from '../component-meta'
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
|
-
description:
|
|
5
|
-
'Horizontal flex layout that WRAPS to the next line when items overflow.',
|
|
4
|
+
description: 'Horizontal flex layout that WRAPS to the next line when items overflow.',
|
|
6
5
|
whenToUse: [
|
|
7
6
|
'Tags, badges, or chips that should wrap when they exceed the container width',
|
|
8
7
|
'Any set of inline elements with unknown/dynamic count',
|
|
9
8
|
'Filter pills, keyword lists, avatar groups',
|
|
10
9
|
],
|
|
11
|
-
whenNotToUse: [
|
|
12
|
-
'Items that must stay on one line — use Inline',
|
|
13
|
-
'Vertical stacking — use Stack',
|
|
14
|
-
],
|
|
10
|
+
whenNotToUse: ['Items that must stay on one line — use Inline', 'Vertical stacking — use Stack'],
|
|
15
11
|
relatedTo: [
|
|
16
12
|
{component: 'Inline', relationship: 'Same but does NOT wrap (nowrap)'},
|
|
17
13
|
{component: 'Stack', relationship: 'Vertical equivalent'},
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, within, userEvent} from 'storybook/test'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
4
|
+
import {useState} from 'react'
|
|
5
|
+
import {Dialog} from './Dialog'
|
|
6
|
+
import {Button} from '../Button/Button'
|
|
7
|
+
import {Text} from '../Text/Text'
|
|
8
|
+
import {Inline} from '../Inline/Inline'
|
|
9
|
+
import {Stack} from '../Stack/Stack'
|
|
10
|
+
import {Input} from '../Input/Input'
|
|
11
|
+
import {Field} from '../Field/Field'
|
|
12
|
+
import {Heading} from '../Heading/Heading'
|
|
13
|
+
|
|
14
|
+
const meta: Meta = {
|
|
15
|
+
title: 'Components/Dialog',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default meta
|
|
19
|
+
type Story = StoryObj
|
|
20
|
+
|
|
21
|
+
// --- Default ---
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<Dialog.Root>
|
|
26
|
+
<Dialog.Trigger>
|
|
27
|
+
<Button>Open dialog</Button>
|
|
28
|
+
</Dialog.Trigger>
|
|
29
|
+
<Dialog.Portal>
|
|
30
|
+
<Dialog.Header>
|
|
31
|
+
<Dialog.Title>Confirm action</Dialog.Title>
|
|
32
|
+
<Dialog.Close />
|
|
33
|
+
</Dialog.Header>
|
|
34
|
+
<Dialog.Body>
|
|
35
|
+
<Text>Are you sure you want to proceed? This action cannot be undone.</Text>
|
|
36
|
+
</Dialog.Body>
|
|
37
|
+
<Dialog.Footer>
|
|
38
|
+
<Dialog.Close>
|
|
39
|
+
<Button variant="secondary">Cancel</Button>
|
|
40
|
+
</Dialog.Close>
|
|
41
|
+
<Button variant="primary">Confirm</Button>
|
|
42
|
+
</Dialog.Footer>
|
|
43
|
+
</Dialog.Portal>
|
|
44
|
+
</Dialog.Root>
|
|
45
|
+
),
|
|
46
|
+
play: async ({canvasElement}) => {
|
|
47
|
+
const canvas = within(canvasElement)
|
|
48
|
+
const trigger = canvas.getByText('Open dialog')
|
|
49
|
+
await expect(trigger).toBeInTheDocument()
|
|
50
|
+
|
|
51
|
+
// Dialog should not be visible initially
|
|
52
|
+
expect(canvasElement.ownerDocument.querySelector('[role="dialog"]')).toBeNull()
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- WithDescription ---
|
|
57
|
+
|
|
58
|
+
export const WithDescription: Story = {
|
|
59
|
+
render: () => (
|
|
60
|
+
<Dialog.Root>
|
|
61
|
+
<Dialog.Trigger>
|
|
62
|
+
<Button variant="danger">Delete account</Button>
|
|
63
|
+
</Dialog.Trigger>
|
|
64
|
+
<Dialog.Portal size="sm">
|
|
65
|
+
<Dialog.Header>
|
|
66
|
+
<Dialog.Title>Delete account</Dialog.Title>
|
|
67
|
+
<Dialog.Close />
|
|
68
|
+
</Dialog.Header>
|
|
69
|
+
<Dialog.Description>
|
|
70
|
+
This will permanently delete your account and all associated data.
|
|
71
|
+
</Dialog.Description>
|
|
72
|
+
<Dialog.Body>
|
|
73
|
+
<Text>Please type "DELETE" to confirm.</Text>
|
|
74
|
+
</Dialog.Body>
|
|
75
|
+
<Dialog.Footer>
|
|
76
|
+
<Dialog.Close>
|
|
77
|
+
<Button variant="secondary">Cancel</Button>
|
|
78
|
+
</Dialog.Close>
|
|
79
|
+
<Button variant="danger">Delete</Button>
|
|
80
|
+
</Dialog.Footer>
|
|
81
|
+
</Dialog.Portal>
|
|
82
|
+
</Dialog.Root>
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Sizes ---
|
|
87
|
+
|
|
88
|
+
function SizeDemo() {
|
|
89
|
+
const [size, setSize] = useState<'sm' | 'md' | 'lg' | null>(null)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Inline gap="sm">
|
|
93
|
+
{(['sm', 'md', 'lg'] as const).map((s) => (
|
|
94
|
+
<Dialog.Root key={s} open={size === s} onOpenChange={(open) => setSize(open ? s : null)}>
|
|
95
|
+
<Dialog.Trigger>
|
|
96
|
+
<Button variant="secondary">{s.toUpperCase()}</Button>
|
|
97
|
+
</Dialog.Trigger>
|
|
98
|
+
<Dialog.Portal size={s}>
|
|
99
|
+
<Dialog.Header>
|
|
100
|
+
<Dialog.Title>Size: {s}</Dialog.Title>
|
|
101
|
+
<Dialog.Close />
|
|
102
|
+
</Dialog.Header>
|
|
103
|
+
<Dialog.Body>
|
|
104
|
+
<Text>
|
|
105
|
+
This dialog uses the <html.strong>{s}</html.strong> size preset.
|
|
106
|
+
</Text>
|
|
107
|
+
</Dialog.Body>
|
|
108
|
+
<Dialog.Footer>
|
|
109
|
+
<Dialog.Close>
|
|
110
|
+
<Button variant="secondary">Close</Button>
|
|
111
|
+
</Dialog.Close>
|
|
112
|
+
</Dialog.Footer>
|
|
113
|
+
</Dialog.Portal>
|
|
114
|
+
</Dialog.Root>
|
|
115
|
+
))}
|
|
116
|
+
</Inline>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const Sizes: Story = {
|
|
121
|
+
render: () => <SizeDemo />,
|
|
122
|
+
play: async ({canvasElement}) => {
|
|
123
|
+
const canvas = within(canvasElement)
|
|
124
|
+
await expect(canvas.getByText('SM')).toBeInTheDocument()
|
|
125
|
+
await expect(canvas.getByText('MD')).toBeInTheDocument()
|
|
126
|
+
await expect(canvas.getByText('LG')).toBeInTheDocument()
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Controlled ---
|
|
131
|
+
|
|
132
|
+
function ControlledDemo() {
|
|
133
|
+
const [open, setOpen] = useState(false)
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<Stack gap="md">
|
|
137
|
+
<Inline gap="sm">
|
|
138
|
+
<Button onClick={() => setOpen(true)}>Open</Button>
|
|
139
|
+
<Text variant="caption">Status: {open ? 'Open' : 'Closed'}</Text>
|
|
140
|
+
</Inline>
|
|
141
|
+
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
142
|
+
<Dialog.Portal>
|
|
143
|
+
<Dialog.Header>
|
|
144
|
+
<Dialog.Title>Controlled dialog</Dialog.Title>
|
|
145
|
+
<Dialog.Close />
|
|
146
|
+
</Dialog.Header>
|
|
147
|
+
<Dialog.Body>
|
|
148
|
+
<Text>This dialog is controlled via the open prop.</Text>
|
|
149
|
+
</Dialog.Body>
|
|
150
|
+
<Dialog.Footer>
|
|
151
|
+
<Button variant="secondary" onClick={() => setOpen(false)}>
|
|
152
|
+
Close programmatically
|
|
153
|
+
</Button>
|
|
154
|
+
</Dialog.Footer>
|
|
155
|
+
</Dialog.Portal>
|
|
156
|
+
</Dialog.Root>
|
|
157
|
+
</Stack>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const Controlled: Story = {
|
|
162
|
+
render: () => <ControlledDemo />,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- WithForm ---
|
|
166
|
+
|
|
167
|
+
function FormDialogDemo() {
|
|
168
|
+
const [open, setOpen] = useState(false)
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
172
|
+
<Dialog.Trigger>
|
|
173
|
+
<Button>Edit profile</Button>
|
|
174
|
+
</Dialog.Trigger>
|
|
175
|
+
<Dialog.Portal size="md">
|
|
176
|
+
<Dialog.Header>
|
|
177
|
+
<Dialog.Title>Edit profile</Dialog.Title>
|
|
178
|
+
<Dialog.Close />
|
|
179
|
+
</Dialog.Header>
|
|
180
|
+
<Dialog.Description>
|
|
181
|
+
Update your display name and email address.
|
|
182
|
+
</Dialog.Description>
|
|
183
|
+
<Dialog.Body>
|
|
184
|
+
<Stack gap="md">
|
|
185
|
+
<Field.Root>
|
|
186
|
+
<Field.Label>Display name</Field.Label>
|
|
187
|
+
<Input placeholder="Enter your name" />
|
|
188
|
+
</Field.Root>
|
|
189
|
+
<Field.Root>
|
|
190
|
+
<Field.Label>Email</Field.Label>
|
|
191
|
+
<Input type="email" placeholder="you@example.com" />
|
|
192
|
+
</Field.Root>
|
|
193
|
+
</Stack>
|
|
194
|
+
</Dialog.Body>
|
|
195
|
+
<Dialog.Footer>
|
|
196
|
+
<Dialog.Close>
|
|
197
|
+
<Button variant="secondary">Cancel</Button>
|
|
198
|
+
</Dialog.Close>
|
|
199
|
+
<Button onClick={() => setOpen(false)}>Save changes</Button>
|
|
200
|
+
</Dialog.Footer>
|
|
201
|
+
</Dialog.Portal>
|
|
202
|
+
</Dialog.Root>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const WithForm: Story = {
|
|
207
|
+
render: () => <FormDialogDemo />,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- Danger confirmation ---
|
|
211
|
+
|
|
212
|
+
function DangerDialogDemo() {
|
|
213
|
+
const [open, setOpen] = useState(false)
|
|
214
|
+
const [confirmed, setConfirmed] = useState(false)
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<Stack gap="md">
|
|
218
|
+
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
219
|
+
<Dialog.Trigger>
|
|
220
|
+
<Button variant="danger">Revoke certificate</Button>
|
|
221
|
+
</Dialog.Trigger>
|
|
222
|
+
<Dialog.Portal size="sm">
|
|
223
|
+
<Dialog.Header>
|
|
224
|
+
<Dialog.Title>Revoke certificate</Dialog.Title>
|
|
225
|
+
<Dialog.Close />
|
|
226
|
+
</Dialog.Header>
|
|
227
|
+
<Dialog.Description>
|
|
228
|
+
This will immediately invalidate the certificate. Connected devices will lose access.
|
|
229
|
+
</Dialog.Description>
|
|
230
|
+
<Dialog.Body>
|
|
231
|
+
<Text variant="bodySm">Certificate: web-server-prod-2024.pem</Text>
|
|
232
|
+
</Dialog.Body>
|
|
233
|
+
<Dialog.Footer>
|
|
234
|
+
<Dialog.Close>
|
|
235
|
+
<Button variant="secondary">Cancel</Button>
|
|
236
|
+
</Dialog.Close>
|
|
237
|
+
<Button
|
|
238
|
+
variant="danger"
|
|
239
|
+
onClick={() => {
|
|
240
|
+
setConfirmed(true)
|
|
241
|
+
setOpen(false)
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
Revoke
|
|
245
|
+
</Button>
|
|
246
|
+
</Dialog.Footer>
|
|
247
|
+
</Dialog.Portal>
|
|
248
|
+
</Dialog.Root>
|
|
249
|
+
{confirmed && <Text variant="bodySm">Certificate revoked.</Text>}
|
|
250
|
+
</Stack>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const DangerConfirmation: Story = {
|
|
255
|
+
render: () => <DangerDialogDemo />,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- Non-dismissable (modal) ---
|
|
259
|
+
|
|
260
|
+
export const NonDismissable: Story = {
|
|
261
|
+
render: () => (
|
|
262
|
+
<Dialog.Root dismissable={false}>
|
|
263
|
+
<Dialog.Trigger>
|
|
264
|
+
<Button>Open non-dismissable</Button>
|
|
265
|
+
</Dialog.Trigger>
|
|
266
|
+
<Dialog.Portal size="sm">
|
|
267
|
+
<Dialog.Header>
|
|
268
|
+
<Dialog.Title>Terms of service</Dialog.Title>
|
|
269
|
+
</Dialog.Header>
|
|
270
|
+
<Dialog.Body>
|
|
271
|
+
<Stack gap="md">
|
|
272
|
+
<Text>
|
|
273
|
+
You must accept the terms of service to continue. This dialog cannot be
|
|
274
|
+
dismissed by clicking outside or pressing Escape.
|
|
275
|
+
</Text>
|
|
276
|
+
<Text variant="bodySm" color="muted">
|
|
277
|
+
Try clicking the backdrop or pressing Escape — the dialog stays open.
|
|
278
|
+
</Text>
|
|
279
|
+
</Stack>
|
|
280
|
+
</Dialog.Body>
|
|
281
|
+
<Dialog.Footer>
|
|
282
|
+
<Dialog.Close>
|
|
283
|
+
<Button variant="secondary">Decline</Button>
|
|
284
|
+
</Dialog.Close>
|
|
285
|
+
<Dialog.Close>
|
|
286
|
+
<Button>Accept</Button>
|
|
287
|
+
</Dialog.Close>
|
|
288
|
+
</Dialog.Footer>
|
|
289
|
+
</Dialog.Portal>
|
|
290
|
+
</Dialog.Root>
|
|
291
|
+
),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- Backdrop dismiss ---
|
|
295
|
+
|
|
296
|
+
function BackdropDismissDemo() {
|
|
297
|
+
const [lastAction, setLastAction] = useState('')
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<Stack gap="md">
|
|
301
|
+
<Dialog.Root onOpenChange={(open) => !open && setLastAction('Dismissed')}>
|
|
302
|
+
<Dialog.Trigger>
|
|
303
|
+
<Button variant="secondary">Open dialog</Button>
|
|
304
|
+
</Dialog.Trigger>
|
|
305
|
+
<Dialog.Portal size="sm">
|
|
306
|
+
<Dialog.Header>
|
|
307
|
+
<Dialog.Title>Click outside to close</Dialog.Title>
|
|
308
|
+
<Dialog.Close />
|
|
309
|
+
</Dialog.Header>
|
|
310
|
+
<Dialog.Body>
|
|
311
|
+
<Text>
|
|
312
|
+
Click the dark backdrop behind this dialog to dismiss it.
|
|
313
|
+
You can also press Escape or use the close button.
|
|
314
|
+
</Text>
|
|
315
|
+
</Dialog.Body>
|
|
316
|
+
</Dialog.Portal>
|
|
317
|
+
</Dialog.Root>
|
|
318
|
+
{lastAction && <Text variant="bodySm">Last action: {lastAction}</Text>}
|
|
319
|
+
</Stack>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export const BackdropDismiss: Story = {
|
|
324
|
+
render: () => <BackdropDismissDemo />,
|
|
325
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
type RefObject,
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useState,
|
|
7
|
+
useEffect,
|
|
8
|
+
useCallback,
|
|
9
|
+
useId,
|
|
10
|
+
useRef,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import {html} from 'react-strict-dom'
|
|
13
|
+
import {styles} from './styles.css'
|
|
14
|
+
|
|
15
|
+
// --- Types ---
|
|
16
|
+
|
|
17
|
+
export type DialogSize = 'sm' | 'md' | 'lg'
|
|
18
|
+
|
|
19
|
+
// --- Context ---
|
|
20
|
+
|
|
21
|
+
interface DialogContextValue {
|
|
22
|
+
open: boolean
|
|
23
|
+
closing: boolean
|
|
24
|
+
dismissable: boolean
|
|
25
|
+
requestOpen: () => void
|
|
26
|
+
requestClose: () => void
|
|
27
|
+
titleId: string
|
|
28
|
+
descriptionId: string
|
|
29
|
+
popupRef: RefObject<HTMLDivElement | null>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DialogContext = createContext<DialogContextValue | null>(null)
|
|
33
|
+
|
|
34
|
+
function useDialog() {
|
|
35
|
+
const ctx = useContext(DialogContext)
|
|
36
|
+
if (!ctx) throw new Error('Dialog compound components must be used within Dialog.Root')
|
|
37
|
+
return ctx
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Root ---
|
|
41
|
+
|
|
42
|
+
interface RootProps {
|
|
43
|
+
children: ReactNode
|
|
44
|
+
/** Controlled open state */
|
|
45
|
+
open?: boolean
|
|
46
|
+
/** Default open state (uncontrolled) */
|
|
47
|
+
defaultOpen?: boolean
|
|
48
|
+
/** Called when dialog open state changes */
|
|
49
|
+
onOpenChange?: (open: boolean) => void
|
|
50
|
+
/** Whether clicking backdrop closes the dialog. Default: true */
|
|
51
|
+
dismissable?: boolean
|
|
52
|
+
/** Duration of the close animation in ms. Default: 140 */
|
|
53
|
+
closeAnimationDuration?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Root({
|
|
57
|
+
children,
|
|
58
|
+
open: controlledOpen,
|
|
59
|
+
defaultOpen = false,
|
|
60
|
+
onOpenChange,
|
|
61
|
+
dismissable = true,
|
|
62
|
+
closeAnimationDuration = 140,
|
|
63
|
+
}: RootProps) {
|
|
64
|
+
const isControlled = controlledOpen !== undefined
|
|
65
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
66
|
+
const [closing, setClosing] = useState(false)
|
|
67
|
+
const closingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
68
|
+
const popupRef = useRef<HTMLDivElement>(null)
|
|
69
|
+
|
|
70
|
+
const isOpen = isControlled ? controlledOpen : internalOpen
|
|
71
|
+
|
|
72
|
+
const titleId = useId()
|
|
73
|
+
const descriptionId = useId()
|
|
74
|
+
|
|
75
|
+
const requestOpen = useCallback(() => {
|
|
76
|
+
if (closingTimerRef.current) {
|
|
77
|
+
clearTimeout(closingTimerRef.current)
|
|
78
|
+
closingTimerRef.current = null
|
|
79
|
+
}
|
|
80
|
+
setClosing(false)
|
|
81
|
+
if (!isControlled) setInternalOpen(true)
|
|
82
|
+
onOpenChange?.(true)
|
|
83
|
+
}, [isControlled, onOpenChange])
|
|
84
|
+
|
|
85
|
+
const requestClose = useCallback(() => {
|
|
86
|
+
setClosing(true)
|
|
87
|
+
closingTimerRef.current = setTimeout(() => {
|
|
88
|
+
setClosing(false)
|
|
89
|
+
closingTimerRef.current = null
|
|
90
|
+
if (!isControlled) setInternalOpen(false)
|
|
91
|
+
onOpenChange?.(false)
|
|
92
|
+
}, closeAnimationDuration)
|
|
93
|
+
}, [isControlled, onOpenChange, closeAnimationDuration])
|
|
94
|
+
|
|
95
|
+
// If controlled open goes false externally, trigger close animation
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (isControlled && !controlledOpen && !closing) {
|
|
98
|
+
// Already closed, nothing to animate
|
|
99
|
+
}
|
|
100
|
+
}, [isControlled, controlledOpen, closing])
|
|
101
|
+
|
|
102
|
+
// Clean up timer on unmount
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
return () => {
|
|
105
|
+
if (closingTimerRef.current) clearTimeout(closingTimerRef.current)
|
|
106
|
+
}
|
|
107
|
+
}, [])
|
|
108
|
+
|
|
109
|
+
// Handle Escape key
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!isOpen && !closing) return
|
|
112
|
+
if (!dismissable) return
|
|
113
|
+
|
|
114
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
115
|
+
if (e.key === 'Escape') {
|
|
116
|
+
e.stopPropagation()
|
|
117
|
+
requestClose()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
document.addEventListener('keydown', onKeyDown)
|
|
121
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
122
|
+
}, [isOpen, closing, dismissable, requestClose])
|
|
123
|
+
|
|
124
|
+
// Lock body scroll when open
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (isOpen || closing) {
|
|
127
|
+
const prev = document.body.style.overflow
|
|
128
|
+
document.body.style.overflow = 'hidden'
|
|
129
|
+
return () => {
|
|
130
|
+
document.body.style.overflow = prev
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}, [isOpen, closing])
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<DialogContext.Provider
|
|
137
|
+
value={{
|
|
138
|
+
open: isOpen || closing,
|
|
139
|
+
closing,
|
|
140
|
+
dismissable,
|
|
141
|
+
requestOpen,
|
|
142
|
+
requestClose,
|
|
143
|
+
titleId,
|
|
144
|
+
descriptionId,
|
|
145
|
+
popupRef,
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
{children}
|
|
149
|
+
</DialogContext.Provider>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Trigger ---
|
|
154
|
+
|
|
155
|
+
interface TriggerProps {
|
|
156
|
+
children: ReactNode
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function Trigger({children}: TriggerProps) {
|
|
160
|
+
const {requestOpen} = useDialog()
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<html.div onClick={requestOpen} style={styles.inlineWrapper}>
|
|
164
|
+
{children}
|
|
165
|
+
</html.div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Portal (renders backdrop + viewport + popup) ---
|
|
170
|
+
|
|
171
|
+
interface PortalProps {
|
|
172
|
+
children: ReactNode
|
|
173
|
+
size?: DialogSize
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function Portal({children, size = 'md'}: PortalProps) {
|
|
177
|
+
const {open, closing, dismissable, requestClose, titleId, descriptionId, popupRef} = useDialog()
|
|
178
|
+
|
|
179
|
+
const handleBackdropClick = useCallback(() => {
|
|
180
|
+
if (dismissable) {
|
|
181
|
+
requestClose()
|
|
182
|
+
}
|
|
183
|
+
}, [dismissable, requestClose])
|
|
184
|
+
|
|
185
|
+
if (!open) return null
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<>
|
|
189
|
+
{/* Backdrop — click to dismiss */}
|
|
190
|
+
<html.div
|
|
191
|
+
style={[
|
|
192
|
+
styles.backdrop,
|
|
193
|
+
closing && styles.backdropClosing,
|
|
194
|
+
!closing && styles.backdropOpen,
|
|
195
|
+
]}
|
|
196
|
+
aria-hidden
|
|
197
|
+
onClick={handleBackdropClick}
|
|
198
|
+
/>
|
|
199
|
+
|
|
200
|
+
{/* Viewport (centering container, pointer-events: none) */}
|
|
201
|
+
<html.div style={styles.viewport}>
|
|
202
|
+
<html.div
|
|
203
|
+
ref={popupRef}
|
|
204
|
+
role="dialog"
|
|
205
|
+
aria-modal="true"
|
|
206
|
+
aria-labelledby={titleId}
|
|
207
|
+
aria-describedby={descriptionId}
|
|
208
|
+
style={[
|
|
209
|
+
styles.popup,
|
|
210
|
+
styles[size],
|
|
211
|
+
closing && styles.popupClosing,
|
|
212
|
+
!closing && styles.popupOpen,
|
|
213
|
+
]}
|
|
214
|
+
>
|
|
215
|
+
{children}
|
|
216
|
+
</html.div>
|
|
217
|
+
</html.div>
|
|
218
|
+
</>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Header ---
|
|
223
|
+
|
|
224
|
+
function Header({children}: {children: ReactNode}) {
|
|
225
|
+
return <html.div style={styles.header}>{children}</html.div>
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- Title ---
|
|
229
|
+
|
|
230
|
+
function Title({children}: {children: ReactNode}) {
|
|
231
|
+
const {titleId} = useDialog()
|
|
232
|
+
return (
|
|
233
|
+
<html.h2 id={titleId} style={styles.title}>
|
|
234
|
+
{children}
|
|
235
|
+
</html.h2>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Description ---
|
|
240
|
+
|
|
241
|
+
function Description({children}: {children: ReactNode}) {
|
|
242
|
+
const {descriptionId} = useDialog()
|
|
243
|
+
return (
|
|
244
|
+
<html.p id={descriptionId} style={styles.description}>
|
|
245
|
+
{children}
|
|
246
|
+
</html.p>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- Body ---
|
|
251
|
+
|
|
252
|
+
function Body({children}: {children: ReactNode}) {
|
|
253
|
+
return <html.div style={styles.body}>{children}</html.div>
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- Footer ---
|
|
257
|
+
|
|
258
|
+
function Footer({children}: {children: ReactNode}) {
|
|
259
|
+
return <html.div style={styles.footer}>{children}</html.div>
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- Close ---
|
|
263
|
+
|
|
264
|
+
interface CloseProps {
|
|
265
|
+
children?: ReactNode
|
|
266
|
+
'aria-label'?: string
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function Close({children, 'aria-label': ariaLabel = 'Close'}: CloseProps) {
|
|
270
|
+
const {requestClose} = useDialog()
|
|
271
|
+
|
|
272
|
+
if (children) {
|
|
273
|
+
return (
|
|
274
|
+
<html.div onClick={requestClose} style={styles.inlineWrapper}>
|
|
275
|
+
{children}
|
|
276
|
+
</html.div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<html.button
|
|
282
|
+
onClick={requestClose}
|
|
283
|
+
aria-label={ariaLabel}
|
|
284
|
+
style={styles.closeButton}
|
|
285
|
+
>
|
|
286
|
+
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
|
|
287
|
+
<path
|
|
288
|
+
d="M4 4l8 8M12 4l-8 8"
|
|
289
|
+
stroke="currentColor"
|
|
290
|
+
strokeWidth={1.5}
|
|
291
|
+
strokeLinecap="round"
|
|
292
|
+
/>
|
|
293
|
+
</svg>
|
|
294
|
+
</html.button>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- Export ---
|
|
299
|
+
|
|
300
|
+
export const Dialog = {
|
|
301
|
+
Root,
|
|
302
|
+
Trigger,
|
|
303
|
+
Portal,
|
|
304
|
+
Header,
|
|
305
|
+
Title,
|
|
306
|
+
Description,
|
|
307
|
+
Body,
|
|
308
|
+
Footer,
|
|
309
|
+
Close,
|
|
310
|
+
}
|