@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.
Files changed (116) hide show
  1. package/dist/Combobox/Combobox.d.ts +54 -0
  2. package/dist/Combobox/Combobox.d.ts.map +1 -0
  3. package/dist/Combobox/Combobox.js +216 -0
  4. package/dist/Combobox/Combobox.js.map +1 -0
  5. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  7. package/dist/Combobox/ComboboxOption.js +32 -0
  8. package/dist/Combobox/ComboboxOption.js.map +1 -0
  9. package/dist/Dialog/Dialog.d.ts +47 -0
  10. package/dist/Dialog/Dialog.d.ts.map +1 -0
  11. package/dist/Dialog/Dialog.js +52 -0
  12. package/dist/Dialog/Dialog.js.map +1 -0
  13. package/dist/Lightbox/Lightbox.d.ts +7 -7
  14. package/dist/Lightbox/Lightbox.js +9 -9
  15. package/dist/Lightbox/Lightbox.js.map +1 -1
  16. package/dist/Listbox/Listbox.d.ts +56 -0
  17. package/dist/Listbox/Listbox.d.ts.map +1 -0
  18. package/dist/Listbox/Listbox.js +151 -0
  19. package/dist/Listbox/Listbox.js.map +1 -0
  20. package/dist/Listbox/ListboxOption.d.ts +36 -0
  21. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  22. package/dist/Listbox/ListboxOption.js +42 -0
  23. package/dist/Listbox/ListboxOption.js.map +1 -0
  24. package/dist/Menu/Menu.d.ts.map +1 -1
  25. package/dist/Menu/Menu.js +47 -97
  26. package/dist/Menu/Menu.js.map +1 -1
  27. package/dist/Modal/Modal.d.ts +11 -59
  28. package/dist/Modal/Modal.d.ts.map +1 -1
  29. package/dist/Modal/Modal.js +28 -101
  30. package/dist/Modal/Modal.js.map +1 -1
  31. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  32. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  33. package/dist/Overlay/OverlayPanel.js +72 -0
  34. package/dist/Overlay/OverlayPanel.js.map +1 -0
  35. package/dist/Overlay/OverlayStack.d.ts +47 -0
  36. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  37. package/dist/Overlay/OverlayStack.js +46 -0
  38. package/dist/Overlay/OverlayStack.js.map +1 -0
  39. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  40. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  41. package/dist/Overlay/SharedBackdrop.js +28 -0
  42. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  43. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  44. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  45. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  46. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  47. package/dist/Overlay/overlayPanelClass.js +17 -0
  48. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  49. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  50. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  51. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  52. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  53. package/dist/Popover/Popover.d.ts +16 -24
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +32 -68
  56. package/dist/Popover/Popover.js.map +1 -1
  57. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  58. package/dist/Tooltip/Tooltip.js +25 -25
  59. package/dist/Tooltip/Tooltip.js.map +1 -1
  60. package/dist/index.d.ts +7 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/mountStory.testHelpers.d.ts +1 -0
  65. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  66. package/dist/mountStory.testHelpers.js +21 -1
  67. package/dist/mountStory.testHelpers.js.map +1 -1
  68. package/package.json +3 -2
  69. package/src/Combobox/Combobox.mdx +71 -0
  70. package/src/Combobox/Combobox.stories.tsx +245 -0
  71. package/src/Combobox/Combobox.test.tsx +289 -0
  72. package/src/Combobox/Combobox.tsx +513 -0
  73. package/src/Combobox/ComboboxOption.tsx +88 -0
  74. package/src/Dialog/Dialog.mdx +87 -0
  75. package/src/Dialog/Dialog.stories.tsx +347 -0
  76. package/src/Dialog/Dialog.test.tsx +312 -0
  77. package/src/Dialog/Dialog.tsx +127 -0
  78. package/src/Field/Field.test.tsx +6 -4
  79. package/src/Lightbox/Lightbox.mdx +9 -8
  80. package/src/Lightbox/Lightbox.stories.tsx +5 -5
  81. package/src/Lightbox/Lightbox.test.tsx +25 -36
  82. package/src/Lightbox/Lightbox.tsx +10 -10
  83. package/src/Listbox/Listbox.mdx +68 -0
  84. package/src/Listbox/Listbox.stories.tsx +205 -0
  85. package/src/Listbox/Listbox.test.tsx +198 -0
  86. package/src/Listbox/Listbox.tsx +295 -0
  87. package/src/Listbox/ListboxOption.tsx +104 -0
  88. package/src/Menu/Menu.mdx +11 -13
  89. package/src/Menu/Menu.test.tsx +47 -38
  90. package/src/Menu/Menu.tsx +81 -142
  91. package/src/Modal/Modal.mdx +29 -82
  92. package/src/Modal/Modal.stories.tsx +55 -188
  93. package/src/Modal/Modal.test.tsx +44 -177
  94. package/src/Modal/Modal.tsx +54 -204
  95. package/src/Overlay/OverlayPanel.tsx +195 -0
  96. package/src/Overlay/OverlayStack.test.tsx +235 -0
  97. package/src/Overlay/OverlayStack.tsx +132 -0
  98. package/src/Overlay/SharedBackdrop.tsx +40 -0
  99. package/src/Overlay/overlayPanelClass.ts +17 -0
  100. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  101. package/src/Popover/Popover.mdx +17 -20
  102. package/src/Popover/Popover.stories.tsx +33 -0
  103. package/src/Popover/Popover.test.tsx +68 -32
  104. package/src/Popover/Popover.tsx +56 -95
  105. package/src/Tooltip/Tooltip.mdx +12 -9
  106. package/src/Tooltip/Tooltip.test.tsx +17 -19
  107. package/src/Tooltip/Tooltip.tsx +35 -32
  108. package/src/index.ts +13 -4
  109. package/src/mountStory.testHelpers.ts +25 -1
  110. package/src/sourceRules.test.ts +12 -3
  111. package/src/storyControls.test.ts +25 -12
  112. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  113. package/dist/Modal/lockScrollBehind.js.map +0 -1
  114. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  115. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  116. /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
+ })