@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
@@ -2,29 +2,16 @@ import { useVisibility } from "@charcuterie/logic"
2
2
  import type { Meta, StoryObj } from "@storybook/react"
3
3
  import type { ReactNode } from "react"
4
4
 
5
- import { toStoryChoice } from "../argTypes.storyHelpers.ts"
6
5
  import { Button } from "../Button/Button.tsx"
7
- import { StoryRow } from "../board.storyHelpers.tsx"
8
- import type { ModalSize } from "./Modal.tsx"
9
6
  import { Modal } from "./Modal.tsx"
10
7
 
11
8
  const meta = {
12
9
  title: "Components/Modal",
13
10
  component: Modal,
14
11
  parameters: { layout: "padded" },
15
- argTypes: {
16
- size: toStoryChoice([
17
- "sm",
18
- "md",
19
- "lg",
20
- "xl",
21
- "full",
22
- ] as const),
23
- },
24
12
  args: {
25
- headingLevel: 2,
26
13
  isDismissable: true,
27
- size: "md",
14
+ role: "dialog",
28
15
  },
29
16
  } satisfies Meta<typeof Modal>
30
17
 
@@ -33,32 +20,21 @@ export default meta
33
20
  type Story = StoryObj<typeof meta>
34
21
 
35
22
  /**
36
- * Every board here is triggers-plus-one-open-dialog rather than a
37
- * grid, and that is the component rather than the stories being
38
- * awkward: `showModal()` puts a dialog in the **top layer**, so two
39
- * of them side by side is not a thing the platform can do. What the
40
- * boards show instead is the real arrangement — a page, a scrim,
41
- * and one dialog over both.
42
- *
43
- * `footer` is a function of `hide` because a dialog's own buttons
44
- * are the normal way out of it, and they need the same `hide` the
45
- * close button gets. One `useVisibility` per demo, no second source
46
- * of truth.
23
+ * The base layer has no chrome, so a demo supplies its own — the
24
+ * point being that `Modal` is the backdrop-plus-dismiss-plus-portal
25
+ * foundation and the content is entirely the caller's. `Dialog` is
26
+ * the batteries-included version of exactly this.
47
27
  */
48
28
  const ModalDemo = ({
49
29
  buttonLabel,
50
30
  children,
51
- footer,
52
- heading,
53
- isDismissable = true,
54
- size,
31
+ label,
32
+ role,
55
33
  }: {
56
34
  buttonLabel: string
57
35
  children?: ReactNode
58
- footer?: (hide: () => void) => ReactNode
59
- heading: string
60
- isDismissable?: boolean
61
- size?: ModalSize
36
+ label: string
37
+ role?: "alertdialog" | "dialog"
62
38
  }): ReactNode => {
63
39
  const { hide, isVisible, show } = useVisibility()
64
40
 
@@ -69,21 +45,29 @@ const ModalDemo = ({
69
45
  </Button>
70
46
 
71
47
  <Modal
72
- footer={footer?.(hide)}
73
- heading={heading}
74
- isDismissable={isDismissable}
48
+ aria-label={label}
75
49
  isVisible={isVisible}
76
50
  onClose={hide}
77
- size={size}
51
+ role={role}
78
52
  >
79
- {children ?? (
80
- <p className="text-content-secondary text-sm">
81
- MakeMKV reported a read error on title 4. The
82
- rip can continue with the remaining eight
83
- titles, or stop here and keep what has
84
- completed.
85
- </p>
86
- )}
53
+ <div className="flex flex-col gap-3 p-4">
54
+ {children ?? (
55
+ <p className="text-content-secondary text-sm">
56
+ A bare `Modal`: a scrim, a focus trap, Escape,
57
+ and an outside press. Everything you see
58
+ inside is the caller's own markup.
59
+ </p>
60
+ )}
61
+
62
+ <Button
63
+ appearance="soft"
64
+ intent="neutral"
65
+ onClick={hide}
66
+ size="sm"
67
+ >
68
+ Close
69
+ </Button>
70
+ </div>
87
71
  </Modal>
88
72
  </>
89
73
  )
@@ -91,182 +75,65 @@ const ModalDemo = ({
91
75
 
92
76
  export const Default: Story = {
93
77
  args: {
94
- heading: "Bay 3",
78
+ "aria-label": "Read error on title 4",
79
+ children: null,
95
80
  isVisible: false,
96
81
  onClose: () => {},
97
82
  },
98
83
  render: () => (
99
84
  <ModalDemo
100
- buttonLabel="Show the read error"
101
- heading="Read error on title 4"
85
+ buttonLabel="Open the base modal"
86
+ label="Read error on title 4"
102
87
  />
103
88
  ),
104
89
  }
105
90
 
106
- const SIZES: ModalSize[] = ["sm", "md", "lg", "xl", "full"]
107
-
108
- export const AllVariants: Story = {
109
- args: {
110
- heading: "Bay 3",
111
- isVisible: false,
112
- onClose: () => {},
113
- },
114
- render: () => (
115
- <StoryRow>
116
- {SIZES.map((size) => (
117
- <ModalDemo
118
- buttonLabel={`Open ${size}`}
119
- heading={`Read error — ${size}`}
120
- key={size}
121
- size={size}
122
- />
123
- ))}
124
- </StoryRow>
125
- ),
126
- }
127
-
128
- export const AllStates: Story = {
129
- args: {
130
- heading: "Bay 3",
131
- isVisible: false,
132
- onClose: () => {},
133
- },
134
- render: () => (
135
- <StoryRow>
136
- <ModalDemo
137
- buttonLabel="With a footer"
138
- footer={(hide) => (
139
- <>
140
- <Button
141
- appearance="soft"
142
- intent="neutral"
143
- onClick={hide}
144
- size="sm"
145
- >
146
- Keep going
147
- </Button>
148
-
149
- <Button intent="danger" size="sm">
150
- Stop the rip
151
- </Button>
152
- </>
153
- )}
154
- heading="Stop the rip?"
155
- />
156
-
157
- <ModalDemo
158
- buttonLabel="Not dismissable"
159
- footer={(hide) => (
160
- <>
161
- <Button
162
- appearance="soft"
163
- intent="neutral"
164
- onClick={hide}
165
- size="sm"
166
- >
167
- Keep the titles
168
- </Button>
169
-
170
- <Button intent="danger" size="sm">
171
- Erase and re-rip
172
- </Button>
173
- </>
174
- )}
175
- heading="This erases eight completed titles"
176
- isDismissable={false}
177
- />
178
-
179
- <ModalDemo
180
- buttonLabel="Long body"
181
- heading="MakeMKV log"
182
- >
183
- <div className="flex flex-col gap-2">
184
- {Array.from(
185
- { length: 24 },
186
- (_, index) =>
187
- `14:0${index % 10}:22 title ${
188
- index + 1
189
- } AACS handshake complete, reading…`,
190
- ).map((line) => (
191
- <p
192
- className="font-mono text-content-secondary text-xs"
193
- key={line}
194
- >
195
- {line}
196
- </p>
197
- ))}
198
- </div>
199
- </ModalDemo>
200
- </StoryRow>
201
- ),
202
- }
203
-
204
91
  /**
205
- * A modal is the one surface in this library that is **not** a
206
- * container-query citizen, so it gets no `ContainerBoard`. Its size
207
- * is measured against the viewport by definition — it is the thing
208
- * covering the viewport — and boarding it at three container widths
209
- * would be measuring something that has no bearing on it.
210
- *
211
- * What does vary is the clamp: every size caps at `85dvh`, and
212
- * `full` takes the lot. `dvh` rather than `vh` because on a phone
213
- * the two differ by the address bar, and a dialog whose footer is
214
- * underneath it is a dialog with no buttons.
92
+ * `role="alertdialog"` is the one configuration the base exposes that
93
+ * `Dialog` does not surface directly — an interruption the user has
94
+ * to answer, announced as an alert rather than a plain dialog.
215
95
  */
216
- export const Responsive: Story = {
96
+ export const Alert: Story = {
217
97
  args: {
218
- heading: "Bay 3",
98
+ "aria-label": "Confirm erase",
99
+ children: null,
219
100
  isVisible: false,
220
101
  onClose: () => {},
221
102
  },
222
103
  render: () => (
223
104
  <ModalDemo
224
- buttonLabel="Open full-bleed"
225
- heading="MakeMKV log — full"
226
- size="full"
105
+ buttonLabel="Open an alertdialog"
106
+ label="Confirm erase"
107
+ role="alertdialog"
227
108
  >
228
109
  <p className="text-content-secondary text-sm">
229
- `full` is the kiosk and phone case: the dialog is
230
- the screen, so there is nothing behind it worth
231
- showing.
110
+ This erases eight completed titles.
232
111
  </p>
233
112
  </ModalDemo>
234
113
  ),
235
114
  }
236
115
 
237
116
  /**
238
- * The keyboard contract, driven. `showModal()` supplies the focus
239
- * trap and the focus restore; what this asserts is that the state
240
- * layer never disagrees with the element — Escape routes through
241
- * `onCancel` → `hide()` → the effect → `close()`, rather than the
242
- * browser closing the dialog behind the app's back.
117
+ * The keyboard contract, driven. `OverlayPanel`'s
118
+ * `FloatingFocusManager` traps focus and restores it to the trigger;
119
+ * `useDismiss` routes Escape and an outside press through `onClose`.
243
120
  */
244
121
  export const Interactive: Story = {
245
122
  args: {
246
- heading: "Bay 3",
123
+ "aria-label": "Stop the rip?",
124
+ children: null,
247
125
  isVisible: false,
248
126
  onClose: () => {},
249
127
  },
250
128
  render: () => (
251
129
  <ModalDemo
252
130
  buttonLabel="Stop the rip"
253
- footer={(hide) => (
254
- <>
255
- <Button
256
- appearance="soft"
257
- intent="neutral"
258
- onClick={hide}
259
- size="sm"
260
- >
261
- Keep going
262
- </Button>
263
-
264
- <Button intent="danger" size="sm">
265
- Stop it now
266
- </Button>
267
- </>
268
- )}
269
- heading="Stop the rip?"
270
- />
131
+ label="Stop the rip?"
132
+ >
133
+ <p className="text-content-secondary text-sm">
134
+ Escape, an outside press, or the button below all
135
+ land on the same `onClose`.
136
+ </p>
137
+ </ModalDemo>
271
138
  ),
272
139
  }
@@ -7,216 +7,85 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./Modal.stories.tsx"
9
9
 
10
- const {
11
- AllStates,
12
- AllVariants,
13
- Default,
14
- Interactive,
15
- Responsive,
16
- } = composeStories(stories)
17
-
18
- /**
19
- * Escape, as far as a test can press it — and the limit is worth
20
- * stating rather than working around quietly.
21
- *
22
- * `userEvent` is `@testing-library/user-event`, which dispatches
23
- * **untrusted** events. The browser runs a default action only for
24
- * trusted input, so a synthetic Escape keydown does not make a
25
- * native `<dialog>` fire `cancel` — it does nothing at all. A test
26
- * asserting "Escape did not close the non-dismissable dialog"
27
- * against a synthetic keypress therefore passes for the wrong
28
- * reason, forever, which is worse than not testing it.
29
- *
30
- * So the close request is dispatched where the browser would raise
31
- * it. That splits the contract honestly: the keystroke → `cancel`
32
- * half is the platform's and is not ours to test, and everything
33
- * downstream of `cancel` — `preventDefault`, `hide()`, the effect,
34
- * `close()`, focus restore — is ours and is asserted here.
35
- */
36
- const requestClose = (dialog: HTMLElement) => {
37
- dialog.dispatchEvent(
38
- new Event("cancel", { cancelable: true }),
39
- )
40
- }
10
+ const { Alert, Default, Interactive } =
11
+ composeStories(stories)
41
12
 
42
- test("the dialog is reachable from a scoped query", async () => {
43
- const { canvas, canvasElement } =
13
+ test("the base modal portals to the body, named by its aria-label", async () => {
14
+ const { body, canvas, canvasElement } =
44
15
  await mountStory(Default)
45
16
 
46
17
  await userEvent.click(
47
18
  expectAgentDrivable(canvas, {
48
- name: "Show the read error",
19
+ name: "Open the base modal",
49
20
  role: "button",
50
21
  }),
51
22
  )
52
23
 
53
- // Scoped to `canvas`, and that is the point of not portalling:
54
- // the dialog paints in the top layer while staying inside the
55
- // story root, so an agent's scoped query still reaches it.
56
- const dialog = expectAgentDrivable(canvas, {
24
+ const modal = expectAgentDrivable(body, {
57
25
  name: "Read error on title 4",
58
26
  role: "dialog",
59
27
  })
60
28
 
61
- await expect(dialog).toHaveAttribute("open")
29
+ // Portalled out of the canvas — the whole point over the top layer.
30
+ await expect(canvasElement).not.toContainElement(modal)
62
31
 
63
- // The addon's own axe pass fired before any of this was clicked,
64
- // so the *open* dialog audits itself.
65
- await expectNoAxeViolations(canvasElement)
32
+ await expectNoAxeViolations(modal)
66
33
  })
67
34
 
68
- test("size is a real width, not a class that happens to be there", async () => {
69
- const { canvas } = await mountStory(AllVariants)
35
+ test("an alertdialog role is carried through", async () => {
36
+ const { body, canvas } = await mountStory(Alert)
70
37
 
71
38
  await userEvent.click(
72
39
  expectAgentDrivable(canvas, {
73
- name: "Open lg",
40
+ name: "Open an alertdialog",
74
41
  role: "button",
75
42
  }),
76
43
  )
77
44
 
78
- const dialog = expectAgentDrivable(canvas, {
79
- name: "Read error — lg",
80
- role: "dialog",
45
+ // The one configuration the base surfaces that `Dialog` does not.
46
+ expectAgentDrivable(body, {
47
+ name: "Confirm erase",
48
+ role: "alertdialog",
81
49
  })
82
-
83
- await expect(
84
- dialog.getBoundingClientRect().width,
85
- ).toBeGreaterThan(400)
86
50
  })
87
51
 
88
- test("a non-dismissable dialog cancels the close rather than ignoring it", async () => {
89
- const { canvas } = await mountStory(AllStates)
90
-
91
- await userEvent.click(
92
- expectAgentDrivable(canvas, {
93
- name: "Not dismissable",
94
- role: "button",
95
- }),
96
- )
52
+ test("Escape and an outside press both close through onClose", async () => {
53
+ const { body, canvas } = await mountStory(Default)
97
54
 
98
- const dialog = expectAgentDrivable(canvas, {
99
- name: "This erases eight completed titles",
100
- role: "dialog",
55
+ const trigger = expectAgentDrivable(canvas, {
56
+ name: "Open the base modal",
57
+ role: "button",
101
58
  })
102
59
 
103
- // There is no Close button, and the close request is *cancelled*
104
- // rather than ignored — letting the browser close it would leave
105
- // `isVisible` true and the element shut, which is the desync the
106
- // component exists to prevent.
107
- await expect(
108
- canvas.queryByRole("button", { name: "Close" }),
109
- ).toBeNull()
110
-
111
- requestClose(dialog)
112
-
113
- // `find*` is async, which is what gives React's update from that
114
- // dispatch a chance to flush. If the request *had* got through,
115
- // this query would fail rather than the assertion below passing by
116
- // being too quick.
117
- const keepButton = await canvas.findByRole("button", {
118
- name: "Keep the titles",
119
- })
60
+ await userEvent.click(trigger)
120
61
 
121
- await expect(dialog).toHaveAttribute("open")
62
+ expectAgentDrivable(body, {
63
+ name: "Read error on title 4",
64
+ role: "dialog",
65
+ })
122
66
 
123
- // The differential half. Without it, "still open" would also be
124
- // satisfied by a dialog nothing can close at all — a green
125
- // assertion about a component that does not work.
126
- await userEvent.click(keepButton)
67
+ await userEvent.keyboard("{Escape}")
127
68
 
128
69
  await waitFor(() => {
129
- expect(canvas.queryByRole("dialog")).toBeNull()
70
+ expect(body.queryByRole("dialog")).toBeNull()
130
71
  })
131
- })
132
72
 
133
- test("a long body scrolls inside the clamp", async () => {
134
- const { canvas, canvasElement } =
135
- await mountStory(AllStates)
136
-
137
- await userEvent.click(
138
- canvas.getByRole("button", { name: "Long body" }),
139
- )
73
+ // Reopened, then dismissed by pressing the page behind it.
74
+ await userEvent.click(trigger)
140
75
 
141
- const log = expectAgentDrivable(canvas, {
142
- name: "MakeMKV log",
143
- role: "dialog",
76
+ await waitFor(() => {
77
+ expect(body.getByRole("dialog")).toBeInTheDocument()
144
78
  })
145
79
 
146
- // The body scrolls, the header and footer do not, and the dialog
147
- // stops at `85dvh` rather than running off the screen.
148
- await expect(
149
- log.getBoundingClientRect().height,
150
- ).toBeLessThan(globalThis.innerHeight)
80
+ await userEvent.click(document.body)
151
81
 
152
- await expect(
153
- canvas.getByText(/title 24/),
154
- ).toBeInTheDocument()
155
-
156
- // The case that would trip `scrollable-region-focusable` if the
157
- // body scrolled with nothing inside it to focus.
158
- await expectNoAxeViolations(canvasElement)
159
- })
160
-
161
- test("the scrim is the token, not Chromium's default backdrop", async () => {
162
- const { canvas } = await mountStory(Responsive)
163
-
164
- await userEvent.click(
165
- expectAgentDrivable(canvas, {
166
- name: "Open full-bleed",
167
- role: "button",
168
- }),
169
- )
170
-
171
- const dialog = expectAgentDrivable(canvas, {
172
- name: "MakeMKV log — full",
173
- role: "dialog",
82
+ await waitFor(() => {
83
+ expect(body.queryByRole("dialog")).toBeNull()
174
84
  })
175
-
176
- await expect(
177
- dialog.getBoundingClientRect().height,
178
- ).toBeGreaterThan(globalThis.innerHeight * 0.95)
179
-
180
- // `::backdrop` inheriting custom properties from its originating
181
- // element is the half that could silently not work.
182
- //
183
- // Compared against the token's *resolved* value rather than
184
- // against "not transparent", which M4 learned the hard way:
185
- // Chromium's own `::backdrop` is `rgba(0, 0, 0, 0.1)`, so the
186
- // loose version of this assertion stayed green against a token
187
- // build where `bg-scrim` generated no CSS at all.
188
- //
189
- // The probe is how the two become comparable — the same `var()`
190
- // put through `background-color` comes back in the identical
191
- // serialisation the backdrop uses.
192
- const probe = document.createElement("div")
193
-
194
- probe.style.backgroundColor = "var(--color-scrim)"
195
-
196
- document.body.append(probe)
197
-
198
- const expectedScrim =
199
- globalThis.getComputedStyle(probe).backgroundColor
200
-
201
- probe.remove()
202
-
203
- await expect(expectedScrim).not.toBe("rgba(0, 0, 0, 0)")
204
-
205
- await expect(
206
- globalThis.getComputedStyle(dialog, "::backdrop")
207
- .backgroundColor,
208
- ).toBe(expectedScrim)
209
85
  })
210
86
 
211
- /**
212
- * `showModal()` supplies the focus trap and the focus restore; what
213
- * this asserts is that the state layer never disagrees with the
214
- * element — Escape routes through `onCancel` → `hide()` → the
215
- * effect → `close()`, rather than the browser closing the dialog
216
- * behind the app's back.
217
- */
218
87
  test("focus is trapped inside, and restored to the trigger on close", async () => {
219
- const { canvas } = await mountStory(Interactive)
88
+ const { body, canvas } = await mountStory(Interactive)
220
89
 
221
90
  const trigger = expectAgentDrivable(canvas, {
222
91
  name: "Stop the rip",
@@ -225,24 +94,22 @@ test("focus is trapped inside, and restored to the trigger on close", async () =
225
94
 
226
95
  await userEvent.click(trigger)
227
96
 
228
- const dialog = expectAgentDrivable(canvas, {
97
+ const modal = expectAgentDrivable(body, {
229
98
  name: "Stop the rip?",
230
99
  role: "dialog",
231
100
  })
232
101
 
233
- // The trap, from the platform: focus is inside the dialog, and the
234
- // trigger behind it is `inert` and unreachable.
235
- await expect(dialog).toContainElement(
236
- document.activeElement as HTMLElement,
237
- )
102
+ await waitFor(() => {
103
+ expect(modal.contains(document.activeElement)).toBe(
104
+ true,
105
+ )
106
+ })
238
107
 
239
- requestClose(dialog)
108
+ await userEvent.keyboard("{Escape}")
240
109
 
241
110
  await waitFor(() => {
242
- expect(canvas.queryByRole("dialog")).toBeNull()
111
+ expect(body.queryByRole("dialog")).toBeNull()
243
112
  })
244
113
 
245
- // Focus restored to what opened it — also the platform's, and the
246
- // thing hand-rolled modals in the fleet lose.
247
114
  await expect(document.activeElement).toBe(trigger)
248
115
  })