@charcuterie/ui 1.1.0 → 2.0.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/Combobox/Combobox.d.ts +54 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -0
- package/dist/Combobox/Combobox.js +216 -0
- package/dist/Combobox/Combobox.js.map +1 -0
- package/dist/Combobox/ComboboxOption.d.ts +36 -0
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
- package/dist/Combobox/ComboboxOption.js +32 -0
- package/dist/Combobox/ComboboxOption.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +47 -0
- package/dist/Dialog/Dialog.d.ts.map +1 -0
- package/dist/Dialog/Dialog.js +52 -0
- package/dist/Dialog/Dialog.js.map +1 -0
- package/dist/Lightbox/Lightbox.d.ts +7 -7
- package/dist/Lightbox/Lightbox.js +9 -9
- package/dist/Lightbox/Lightbox.js.map +1 -1
- package/dist/Listbox/Listbox.d.ts +56 -0
- package/dist/Listbox/Listbox.d.ts.map +1 -0
- package/dist/Listbox/Listbox.js +151 -0
- package/dist/Listbox/Listbox.js.map +1 -0
- package/dist/Listbox/ListboxOption.d.ts +36 -0
- package/dist/Listbox/ListboxOption.d.ts.map +1 -0
- package/dist/Listbox/ListboxOption.js +42 -0
- package/dist/Listbox/ListboxOption.js.map +1 -0
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +47 -97
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Modal/Modal.d.ts +11 -59
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +28 -101
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +50 -0
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
- package/dist/Overlay/OverlayPanel.js +72 -0
- package/dist/Overlay/OverlayPanel.js.map +1 -0
- package/dist/Overlay/OverlayStack.d.ts +47 -0
- package/dist/Overlay/OverlayStack.d.ts.map +1 -0
- package/dist/Overlay/OverlayStack.js +46 -0
- package/dist/Overlay/OverlayStack.js.map +1 -0
- package/dist/Overlay/SharedBackdrop.d.ts +24 -0
- package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
- package/dist/Overlay/SharedBackdrop.js +28 -0
- package/dist/Overlay/SharedBackdrop.js.map +1 -0
- package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
- package/dist/Overlay/lockScrollBehind.js.map +1 -0
- package/dist/Overlay/overlayPanelClass.d.ts +17 -0
- package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
- package/dist/Overlay/overlayPanelClass.js +17 -0
- package/dist/Overlay/overlayPanelClass.js.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.js +81 -0
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
- package/dist/Popover/Popover.d.ts +16 -24
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +32 -68
- package/dist/Popover/Popover.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +25 -25
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +1 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +21 -1
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +3 -2
- package/src/Combobox/Combobox.mdx +71 -0
- package/src/Combobox/Combobox.stories.tsx +245 -0
- package/src/Combobox/Combobox.test.tsx +289 -0
- package/src/Combobox/Combobox.tsx +513 -0
- package/src/Combobox/ComboboxOption.tsx +88 -0
- package/src/Dialog/Dialog.mdx +87 -0
- package/src/Dialog/Dialog.stories.tsx +347 -0
- package/src/Dialog/Dialog.test.tsx +312 -0
- package/src/Dialog/Dialog.tsx +127 -0
- package/src/Field/Field.test.tsx +6 -4
- package/src/Lightbox/Lightbox.mdx +9 -8
- package/src/Lightbox/Lightbox.stories.tsx +5 -5
- package/src/Lightbox/Lightbox.test.tsx +25 -36
- package/src/Lightbox/Lightbox.tsx +10 -10
- package/src/Listbox/Listbox.mdx +68 -0
- package/src/Listbox/Listbox.stories.tsx +205 -0
- package/src/Listbox/Listbox.test.tsx +198 -0
- package/src/Listbox/Listbox.tsx +295 -0
- package/src/Listbox/ListboxOption.tsx +104 -0
- package/src/Menu/Menu.mdx +11 -13
- package/src/Menu/Menu.test.tsx +47 -38
- package/src/Menu/Menu.tsx +81 -142
- package/src/Modal/Modal.mdx +29 -82
- package/src/Modal/Modal.stories.tsx +55 -188
- package/src/Modal/Modal.test.tsx +44 -177
- package/src/Modal/Modal.tsx +54 -204
- package/src/Overlay/OverlayPanel.tsx +195 -0
- package/src/Overlay/OverlayStack.test.tsx +235 -0
- package/src/Overlay/OverlayStack.tsx +132 -0
- package/src/Overlay/SharedBackdrop.tsx +40 -0
- package/src/Overlay/overlayPanelClass.ts +17 -0
- package/src/Overlay/useAnchoredOverlay.ts +197 -0
- package/src/Popover/Popover.mdx +17 -20
- package/src/Popover/Popover.stories.tsx +33 -0
- package/src/Popover/Popover.test.tsx +68 -32
- package/src/Popover/Popover.tsx +56 -95
- package/src/Tooltip/Tooltip.mdx +12 -9
- package/src/Tooltip/Tooltip.test.tsx +17 -19
- package/src/Tooltip/Tooltip.tsx +35 -32
- package/src/index.ts +13 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +12 -3
- package/src/storyControls.test.ts +25 -12
- package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
- package/dist/Modal/lockScrollBehind.js.map +0 -1
- /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
- /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
- /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { toStoryChoice } from "../argTypes.storyHelpers.ts"
|
|
6
|
+
import { Button } from "../Button/Button.tsx"
|
|
7
|
+
import { StoryRow } from "../board.storyHelpers.tsx"
|
|
8
|
+
import { OverlayStackProvider } from "../Overlay/OverlayStack.tsx"
|
|
9
|
+
import type { DialogSize } from "./Dialog.tsx"
|
|
10
|
+
import { Dialog } from "./Dialog.tsx"
|
|
11
|
+
|
|
12
|
+
const meta = {
|
|
13
|
+
title: "Components/Dialog",
|
|
14
|
+
component: Dialog,
|
|
15
|
+
parameters: { layout: "padded" },
|
|
16
|
+
argTypes: {
|
|
17
|
+
size: toStoryChoice([
|
|
18
|
+
"sm",
|
|
19
|
+
"md",
|
|
20
|
+
"lg",
|
|
21
|
+
"xl",
|
|
22
|
+
"full",
|
|
23
|
+
] as const),
|
|
24
|
+
},
|
|
25
|
+
args: {
|
|
26
|
+
// `render` supplies the real content; this satisfies the required
|
|
27
|
+
// `children` for the Controls panel's arg type.
|
|
28
|
+
children: null,
|
|
29
|
+
headingLevel: 2,
|
|
30
|
+
isDismissable: true,
|
|
31
|
+
size: "md",
|
|
32
|
+
},
|
|
33
|
+
} satisfies Meta<typeof Dialog>
|
|
34
|
+
|
|
35
|
+
export default meta
|
|
36
|
+
|
|
37
|
+
type Story = StoryObj<typeof meta>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every board here is triggers-plus-one-open-dialog rather than a
|
|
41
|
+
* grid, and that is the component rather than the stories being
|
|
42
|
+
* awkward: a dialog covers the viewport, so two side by side is not
|
|
43
|
+
* the real arrangement. What the boards show instead is a page, a
|
|
44
|
+
* scrim, and one dialog over both.
|
|
45
|
+
*
|
|
46
|
+
* `footer` is a function of `hide` because a dialog's own buttons
|
|
47
|
+
* are the normal way out of it, and they need the same `hide` the
|
|
48
|
+
* close button gets. One `useVisibility` per demo, no second source
|
|
49
|
+
* of truth.
|
|
50
|
+
*/
|
|
51
|
+
const DialogDemo = ({
|
|
52
|
+
buttonLabel,
|
|
53
|
+
children,
|
|
54
|
+
footer,
|
|
55
|
+
heading,
|
|
56
|
+
isDismissable = true,
|
|
57
|
+
size,
|
|
58
|
+
}: {
|
|
59
|
+
buttonLabel: string
|
|
60
|
+
children?: ReactNode
|
|
61
|
+
footer?: (hide: () => void) => ReactNode
|
|
62
|
+
heading: string
|
|
63
|
+
isDismissable?: boolean
|
|
64
|
+
size?: DialogSize
|
|
65
|
+
}): ReactNode => {
|
|
66
|
+
const { hide, isVisible, show } = useVisibility()
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<Button appearance="soft" onClick={show} size="sm">
|
|
71
|
+
{buttonLabel}
|
|
72
|
+
</Button>
|
|
73
|
+
|
|
74
|
+
<Dialog
|
|
75
|
+
footer={footer?.(hide)}
|
|
76
|
+
heading={heading}
|
|
77
|
+
isDismissable={isDismissable}
|
|
78
|
+
isVisible={isVisible}
|
|
79
|
+
onClose={hide}
|
|
80
|
+
size={size}
|
|
81
|
+
>
|
|
82
|
+
{children ?? (
|
|
83
|
+
<p className="text-content-secondary text-sm">
|
|
84
|
+
MakeMKV reported a read error on title 4. The
|
|
85
|
+
rip can continue with the remaining eight
|
|
86
|
+
titles, or stop here and keep what has
|
|
87
|
+
completed.
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
90
|
+
</Dialog>
|
|
91
|
+
</>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const Default: Story = {
|
|
96
|
+
args: {
|
|
97
|
+
heading: "Bay 3",
|
|
98
|
+
isVisible: false,
|
|
99
|
+
onClose: () => {},
|
|
100
|
+
},
|
|
101
|
+
render: () => (
|
|
102
|
+
<DialogDemo
|
|
103
|
+
buttonLabel="Show the read error"
|
|
104
|
+
heading="Read error on title 4"
|
|
105
|
+
/>
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const SIZES: DialogSize[] = ["sm", "md", "lg", "xl", "full"]
|
|
110
|
+
|
|
111
|
+
export const AllVariants: Story = {
|
|
112
|
+
args: {
|
|
113
|
+
heading: "Bay 3",
|
|
114
|
+
isVisible: false,
|
|
115
|
+
onClose: () => {},
|
|
116
|
+
},
|
|
117
|
+
render: () => (
|
|
118
|
+
<StoryRow>
|
|
119
|
+
{SIZES.map((size) => (
|
|
120
|
+
<DialogDemo
|
|
121
|
+
buttonLabel={`Open ${size}`}
|
|
122
|
+
heading={`Read error — ${size}`}
|
|
123
|
+
key={size}
|
|
124
|
+
size={size}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
127
|
+
</StoryRow>
|
|
128
|
+
),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const AllStates: Story = {
|
|
132
|
+
args: {
|
|
133
|
+
heading: "Bay 3",
|
|
134
|
+
isVisible: false,
|
|
135
|
+
onClose: () => {},
|
|
136
|
+
},
|
|
137
|
+
render: () => (
|
|
138
|
+
<StoryRow>
|
|
139
|
+
<DialogDemo
|
|
140
|
+
buttonLabel="With a footer"
|
|
141
|
+
footer={(hide) => (
|
|
142
|
+
<>
|
|
143
|
+
<Button
|
|
144
|
+
appearance="soft"
|
|
145
|
+
intent="neutral"
|
|
146
|
+
onClick={hide}
|
|
147
|
+
size="sm"
|
|
148
|
+
>
|
|
149
|
+
Keep going
|
|
150
|
+
</Button>
|
|
151
|
+
|
|
152
|
+
<Button intent="danger" size="sm">
|
|
153
|
+
Stop the rip
|
|
154
|
+
</Button>
|
|
155
|
+
</>
|
|
156
|
+
)}
|
|
157
|
+
heading="Stop the rip?"
|
|
158
|
+
/>
|
|
159
|
+
|
|
160
|
+
<DialogDemo
|
|
161
|
+
buttonLabel="Not dismissable"
|
|
162
|
+
footer={(hide) => (
|
|
163
|
+
<>
|
|
164
|
+
<Button
|
|
165
|
+
appearance="soft"
|
|
166
|
+
intent="neutral"
|
|
167
|
+
onClick={hide}
|
|
168
|
+
size="sm"
|
|
169
|
+
>
|
|
170
|
+
Keep the titles
|
|
171
|
+
</Button>
|
|
172
|
+
|
|
173
|
+
<Button intent="danger" size="sm">
|
|
174
|
+
Erase and re-rip
|
|
175
|
+
</Button>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
heading="This erases eight completed titles"
|
|
179
|
+
isDismissable={false}
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
<DialogDemo
|
|
183
|
+
buttonLabel="Long body"
|
|
184
|
+
heading="MakeMKV log"
|
|
185
|
+
>
|
|
186
|
+
<div className="flex flex-col gap-2">
|
|
187
|
+
{Array.from(
|
|
188
|
+
{ length: 24 },
|
|
189
|
+
(_, index) =>
|
|
190
|
+
`14:0${index % 10}:22 title ${
|
|
191
|
+
index + 1
|
|
192
|
+
} AACS handshake complete, reading…`,
|
|
193
|
+
).map((line) => (
|
|
194
|
+
<p
|
|
195
|
+
className="font-mono text-content-secondary text-xs"
|
|
196
|
+
key={line}
|
|
197
|
+
>
|
|
198
|
+
{line}
|
|
199
|
+
</p>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
</DialogDemo>
|
|
203
|
+
</StoryRow>
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Stacking — one dialog opened from inside another. An
|
|
209
|
+
* `OverlayStackProvider` at the app root is the one piece of setup a
|
|
210
|
+
* lone dialog does not need: it renders a **single** shared scrim
|
|
211
|
+
* behind both, keeps the second on top, marks the first `inert`, and
|
|
212
|
+
* closes them top-first on Escape or an outside press. Open the first,
|
|
213
|
+
* then open the second from its footer.
|
|
214
|
+
*/
|
|
215
|
+
const StackedDemo = (): ReactNode => {
|
|
216
|
+
const first = useVisibility()
|
|
217
|
+
|
|
218
|
+
const second = useVisibility()
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<OverlayStackProvider>
|
|
222
|
+
<Button
|
|
223
|
+
appearance="soft"
|
|
224
|
+
onClick={first.show}
|
|
225
|
+
size="sm"
|
|
226
|
+
>
|
|
227
|
+
Open the first dialog
|
|
228
|
+
</Button>
|
|
229
|
+
|
|
230
|
+
<Dialog
|
|
231
|
+
footer={
|
|
232
|
+
<Button onClick={second.show} size="sm">
|
|
233
|
+
Open a second over it
|
|
234
|
+
</Button>
|
|
235
|
+
}
|
|
236
|
+
heading="First dialog"
|
|
237
|
+
isVisible={first.isVisible}
|
|
238
|
+
onClose={first.hide}
|
|
239
|
+
>
|
|
240
|
+
<p className="text-content-secondary text-sm">
|
|
241
|
+
This one stays open and goes `inert` while the
|
|
242
|
+
second is over it.
|
|
243
|
+
</p>
|
|
244
|
+
</Dialog>
|
|
245
|
+
|
|
246
|
+
<Dialog
|
|
247
|
+
footer={
|
|
248
|
+
<Button
|
|
249
|
+
appearance="soft"
|
|
250
|
+
intent="neutral"
|
|
251
|
+
onClick={second.hide}
|
|
252
|
+
size="sm"
|
|
253
|
+
>
|
|
254
|
+
Close this one
|
|
255
|
+
</Button>
|
|
256
|
+
}
|
|
257
|
+
heading="Second dialog"
|
|
258
|
+
isVisible={second.isVisible}
|
|
259
|
+
onClose={second.hide}
|
|
260
|
+
>
|
|
261
|
+
<p className="text-content-secondary text-sm">
|
|
262
|
+
One scrim behind both. Escape or a backdrop press
|
|
263
|
+
closes this one first.
|
|
264
|
+
</p>
|
|
265
|
+
</Dialog>
|
|
266
|
+
</OverlayStackProvider>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const Stacked: Story = {
|
|
271
|
+
args: {
|
|
272
|
+
heading: "First dialog",
|
|
273
|
+
isVisible: false,
|
|
274
|
+
onClose: () => {},
|
|
275
|
+
},
|
|
276
|
+
render: () => <StackedDemo />,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* A dialog is the one surface in this library that is **not** a
|
|
281
|
+
* container-query citizen, so it gets no `ContainerBoard`. Its size
|
|
282
|
+
* is measured against the viewport by definition — it is the thing
|
|
283
|
+
* covering the viewport — and boarding it at three container widths
|
|
284
|
+
* would be measuring something that has no bearing on it.
|
|
285
|
+
*
|
|
286
|
+
* What does vary is the clamp: every size caps at `85dvh`, and
|
|
287
|
+
* `full` takes the lot. `dvh` rather than `vh` because on a phone
|
|
288
|
+
* the two differ by the address bar, and a dialog whose footer is
|
|
289
|
+
* underneath it is a dialog with no buttons.
|
|
290
|
+
*/
|
|
291
|
+
export const Responsive: Story = {
|
|
292
|
+
args: {
|
|
293
|
+
heading: "Bay 3",
|
|
294
|
+
isVisible: false,
|
|
295
|
+
onClose: () => {},
|
|
296
|
+
},
|
|
297
|
+
render: () => (
|
|
298
|
+
<DialogDemo
|
|
299
|
+
buttonLabel="Open full-bleed"
|
|
300
|
+
heading="MakeMKV log — full"
|
|
301
|
+
size="full"
|
|
302
|
+
>
|
|
303
|
+
<p className="text-content-secondary text-sm">
|
|
304
|
+
`full` is the kiosk and phone case: the dialog is
|
|
305
|
+
the screen, so there is nothing behind it worth
|
|
306
|
+
showing.
|
|
307
|
+
</p>
|
|
308
|
+
</DialogDemo>
|
|
309
|
+
),
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The keyboard contract, driven. `OverlayPanel`'s
|
|
314
|
+
* `FloatingFocusManager` supplies the focus trap and the focus
|
|
315
|
+
* restore, and `useDismiss` routes Escape through `onClose` — which,
|
|
316
|
+
* unlike the old native `<dialog>`, a synthetic `userEvent.keyboard`
|
|
317
|
+
* can now press for real.
|
|
318
|
+
*/
|
|
319
|
+
export const Interactive: Story = {
|
|
320
|
+
args: {
|
|
321
|
+
heading: "Bay 3",
|
|
322
|
+
isVisible: false,
|
|
323
|
+
onClose: () => {},
|
|
324
|
+
},
|
|
325
|
+
render: () => (
|
|
326
|
+
<DialogDemo
|
|
327
|
+
buttonLabel="Stop the rip"
|
|
328
|
+
footer={(hide) => (
|
|
329
|
+
<>
|
|
330
|
+
<Button
|
|
331
|
+
appearance="soft"
|
|
332
|
+
intent="neutral"
|
|
333
|
+
onClick={hide}
|
|
334
|
+
size="sm"
|
|
335
|
+
>
|
|
336
|
+
Keep going
|
|
337
|
+
</Button>
|
|
338
|
+
|
|
339
|
+
<Button intent="danger" size="sm">
|
|
340
|
+
Stop it now
|
|
341
|
+
</Button>
|
|
342
|
+
</>
|
|
343
|
+
)}
|
|
344
|
+
heading="Stop the rip?"
|
|
345
|
+
/>
|
|
346
|
+
),
|
|
347
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import {
|
|
8
|
+
expectAgentDrivable,
|
|
9
|
+
expectHiddenFromAgents,
|
|
10
|
+
} from "../testing/index.ts"
|
|
11
|
+
import * as stories from "./Dialog.stories.tsx"
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
AllStates,
|
|
15
|
+
AllVariants,
|
|
16
|
+
Default,
|
|
17
|
+
Interactive,
|
|
18
|
+
Responsive,
|
|
19
|
+
Stacked,
|
|
20
|
+
} = composeStories(stories)
|
|
21
|
+
|
|
22
|
+
test("the dialog is reachable from a body-scoped query", async () => {
|
|
23
|
+
const { body, canvas } = await mountStory(Default)
|
|
24
|
+
|
|
25
|
+
await userEvent.click(
|
|
26
|
+
expectAgentDrivable(canvas, {
|
|
27
|
+
name: "Show the read error",
|
|
28
|
+
role: "button",
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// Portalled now, so the panel is found through `body`; the trigger
|
|
33
|
+
// stays in the canvas.
|
|
34
|
+
const dialog = expectAgentDrivable(body, {
|
|
35
|
+
name: "Read error on title 4",
|
|
36
|
+
role: "dialog",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
await expectNoAxeViolations(dialog)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("size is a real width, not a class that happens to be there", async () => {
|
|
43
|
+
const { body, canvas } = await mountStory(AllVariants)
|
|
44
|
+
|
|
45
|
+
await userEvent.click(
|
|
46
|
+
expectAgentDrivable(canvas, {
|
|
47
|
+
name: "Open lg",
|
|
48
|
+
role: "button",
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const dialog = expectAgentDrivable(body, {
|
|
53
|
+
name: "Read error — lg",
|
|
54
|
+
role: "dialog",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
dialog.getBoundingClientRect().width,
|
|
59
|
+
).toBeGreaterThan(400)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("a non-dismissable dialog ignores Escape rather than closing", async () => {
|
|
63
|
+
const { body, canvas } = await mountStory(AllStates)
|
|
64
|
+
|
|
65
|
+
await userEvent.click(
|
|
66
|
+
expectAgentDrivable(canvas, {
|
|
67
|
+
name: "Not dismissable",
|
|
68
|
+
role: "button",
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const _dialog = expectAgentDrivable(body, {
|
|
73
|
+
name: "This erases eight completed titles",
|
|
74
|
+
role: "dialog",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// There is no Close button, and Escape is disabled at the dismiss
|
|
78
|
+
// layer (`isDismissable={false}` → `useDismiss` escapeKey off), so
|
|
79
|
+
// the browser cannot close a dialog the app still owns.
|
|
80
|
+
await expect(
|
|
81
|
+
body.queryByRole("button", { name: "Close" }),
|
|
82
|
+
).toBeNull()
|
|
83
|
+
|
|
84
|
+
await userEvent.keyboard("{Escape}")
|
|
85
|
+
|
|
86
|
+
const keepButton = await body.findByRole("button", {
|
|
87
|
+
name: "Keep the titles",
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
await expect(body.getByRole("dialog")).toBeInTheDocument()
|
|
91
|
+
|
|
92
|
+
// The differential half. Without it, "still open" would also be
|
|
93
|
+
// satisfied by a dialog nothing can close at all.
|
|
94
|
+
await userEvent.click(keepButton)
|
|
95
|
+
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("a long body scrolls inside the clamp", async () => {
|
|
102
|
+
const { body, canvas } = await mountStory(AllStates)
|
|
103
|
+
|
|
104
|
+
await userEvent.click(
|
|
105
|
+
canvas.getByRole("button", { name: "Long body" }),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const log = expectAgentDrivable(body, {
|
|
109
|
+
name: "MakeMKV log",
|
|
110
|
+
role: "dialog",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// The body scrolls, the header and footer do not, and the dialog
|
|
114
|
+
// stops at `85dvh` rather than running off the screen.
|
|
115
|
+
await expect(
|
|
116
|
+
log.getBoundingClientRect().height,
|
|
117
|
+
).toBeLessThan(globalThis.innerHeight)
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
body.getByText(/title 24/),
|
|
121
|
+
).toBeInTheDocument()
|
|
122
|
+
|
|
123
|
+
await expectNoAxeViolations(log)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("the scrim is the token, rendered as the shared backdrop", async () => {
|
|
127
|
+
const { body, canvas } = await mountStory(Responsive)
|
|
128
|
+
|
|
129
|
+
await userEvent.click(
|
|
130
|
+
expectAgentDrivable(canvas, {
|
|
131
|
+
name: "Open full-bleed",
|
|
132
|
+
role: "button",
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const dialog = expectAgentDrivable(body, {
|
|
137
|
+
name: "MakeMKV log — full",
|
|
138
|
+
role: "dialog",
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
await expect(
|
|
142
|
+
dialog.getBoundingClientRect().height,
|
|
143
|
+
).toBeGreaterThan(globalThis.innerHeight * 0.95)
|
|
144
|
+
|
|
145
|
+
// The shared backdrop node renders while a modal is open, and its
|
|
146
|
+
// background really resolves the variant token — not "not
|
|
147
|
+
// transparent", which M4 learned stayed green against a token build
|
|
148
|
+
// where `bg-scrim` generated no CSS at all.
|
|
149
|
+
const scrim = document.body.querySelector(".bg-scrim")
|
|
150
|
+
|
|
151
|
+
await expect(scrim).not.toBeNull()
|
|
152
|
+
|
|
153
|
+
const probe = document.createElement("div")
|
|
154
|
+
|
|
155
|
+
probe.style.backgroundColor = "var(--color-scrim)"
|
|
156
|
+
|
|
157
|
+
document.body.append(probe)
|
|
158
|
+
|
|
159
|
+
const expectedScrim =
|
|
160
|
+
globalThis.getComputedStyle(probe).backgroundColor
|
|
161
|
+
|
|
162
|
+
probe.remove()
|
|
163
|
+
|
|
164
|
+
await expect(expectedScrim).not.toBe("rgba(0, 0, 0, 0)")
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
globalThis.getComputedStyle(scrim as Element)
|
|
168
|
+
.backgroundColor,
|
|
169
|
+
).toBe(expectedScrim)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The keyboard contract, driven — and Escape is pressable for real
|
|
174
|
+
* now, unlike the old native `<dialog>`. `useDismiss` listens for a
|
|
175
|
+
* keydown rather than relying on a UA default action, so
|
|
176
|
+
* `userEvent.keyboard("{Escape}")` is a real close request.
|
|
177
|
+
*/
|
|
178
|
+
test("focus is trapped inside, and Escape restores it to the trigger", async () => {
|
|
179
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
180
|
+
|
|
181
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
182
|
+
name: "Stop the rip",
|
|
183
|
+
role: "button",
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
await userEvent.click(trigger)
|
|
187
|
+
|
|
188
|
+
const dialog = expectAgentDrivable(body, {
|
|
189
|
+
name: "Stop the rip?",
|
|
190
|
+
role: "dialog",
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
expect(dialog.contains(document.activeElement)).toBe(
|
|
195
|
+
true,
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await userEvent.keyboard("{Escape}")
|
|
200
|
+
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Focus restored to what opened it — `FloatingFocusManager`'s
|
|
206
|
+
// `returnFocus`, the thing hand-rolled modals in the fleet lose.
|
|
207
|
+
await expect(document.activeElement).toBe(trigger)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Stacking: one scrim behind two dialogs, the lower one `inert`, and
|
|
212
|
+
* a top-first dismissal on both an outside press and Escape.
|
|
213
|
+
*/
|
|
214
|
+
test("stacked dialogs share one scrim and dismiss top-first", async () => {
|
|
215
|
+
const { body, canvas } = await mountStory(Stacked)
|
|
216
|
+
|
|
217
|
+
await userEvent.click(
|
|
218
|
+
expectAgentDrivable(canvas, {
|
|
219
|
+
name: "Open the first dialog",
|
|
220
|
+
role: "button",
|
|
221
|
+
}),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
// Captured while it is still the only dialog: once the second opens,
|
|
225
|
+
// the first goes `inert` and a role query no longer sees it.
|
|
226
|
+
const first = expectAgentDrivable(body, {
|
|
227
|
+
name: "First dialog",
|
|
228
|
+
role: "dialog",
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
await userEvent.click(
|
|
232
|
+
expectAgentDrivable(body, {
|
|
233
|
+
name: "Open a second over it",
|
|
234
|
+
role: "button",
|
|
235
|
+
}),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const second = expectAgentDrivable(body, {
|
|
239
|
+
name: "Second dialog",
|
|
240
|
+
role: "dialog",
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// One scrim for both — the whole point of the provider.
|
|
244
|
+
await expect(
|
|
245
|
+
document.body.querySelectorAll(".bg-scrim"),
|
|
246
|
+
).toHaveLength(1)
|
|
247
|
+
|
|
248
|
+
// The lower dialog is hidden from assistive tech while the top is
|
|
249
|
+
// open, and focus is trapped in the top.
|
|
250
|
+
expectHiddenFromAgents(first)
|
|
251
|
+
|
|
252
|
+
await waitFor(() => {
|
|
253
|
+
expect(second.contains(document.activeElement)).toBe(
|
|
254
|
+
true,
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// The top dialog's own controls are actually **hittable** — the
|
|
259
|
+
// scrim sits a layer below the panels, so it does not intercept a
|
|
260
|
+
// real click. `userEvent` fires on the element directly and would
|
|
261
|
+
// miss this; `elementFromPoint` is the check that a covering scrim
|
|
262
|
+
// fails.
|
|
263
|
+
const closeButton = expectAgentDrivable(body, {
|
|
264
|
+
name: "Close this one",
|
|
265
|
+
role: "button",
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const buttonRect = closeButton.getBoundingClientRect()
|
|
269
|
+
|
|
270
|
+
const atPoint = document.elementFromPoint(
|
|
271
|
+
buttonRect.left + buttonRect.width / 2,
|
|
272
|
+
buttonRect.top + buttonRect.height / 2,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
await expect(
|
|
276
|
+
atPoint !== null && closeButton.contains(atPoint),
|
|
277
|
+
).toBe(true)
|
|
278
|
+
|
|
279
|
+
// An outside press closes the top only.
|
|
280
|
+
await userEvent.click(document.body)
|
|
281
|
+
|
|
282
|
+
await waitFor(() => {
|
|
283
|
+
expect(
|
|
284
|
+
body.queryByRole("dialog", {
|
|
285
|
+
name: "Second dialog",
|
|
286
|
+
}),
|
|
287
|
+
).toBeNull()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// The first is live again — no longer inert, and the scrim stays.
|
|
291
|
+
await expect(
|
|
292
|
+
document.body.querySelectorAll(".bg-scrim"),
|
|
293
|
+
).toHaveLength(1)
|
|
294
|
+
|
|
295
|
+
const firstAgain = expectAgentDrivable(body, {
|
|
296
|
+
name: "First dialog",
|
|
297
|
+
role: "dialog",
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Escape closes the last one, and the scrim goes with it.
|
|
301
|
+
await userEvent.keyboard("{Escape}")
|
|
302
|
+
|
|
303
|
+
await waitFor(() => {
|
|
304
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
await expect(firstAgain).not.toBeInTheDocument()
|
|
308
|
+
|
|
309
|
+
await expect(
|
|
310
|
+
document.body.querySelector(".bg-scrim"),
|
|
311
|
+
).toBeNull()
|
|
312
|
+
})
|