@charcuterie/ui 1.0.1 → 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 (134) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
  4. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
  8. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
  9. package/dist/Combobox/Combobox.d.ts +54 -0
  10. package/dist/Combobox/Combobox.d.ts.map +1 -0
  11. package/dist/Combobox/Combobox.js +216 -0
  12. package/dist/Combobox/Combobox.js.map +1 -0
  13. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  14. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  15. package/dist/Combobox/ComboboxOption.js +32 -0
  16. package/dist/Combobox/ComboboxOption.js.map +1 -0
  17. package/dist/Dialog/Dialog.d.ts +47 -0
  18. package/dist/Dialog/Dialog.d.ts.map +1 -0
  19. package/dist/Dialog/Dialog.js +52 -0
  20. package/dist/Dialog/Dialog.js.map +1 -0
  21. package/dist/Lightbox/Lightbox.d.ts +57 -0
  22. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  23. package/dist/Lightbox/Lightbox.js +50 -0
  24. package/dist/Lightbox/Lightbox.js.map +1 -0
  25. package/dist/Listbox/Listbox.d.ts +56 -0
  26. package/dist/Listbox/Listbox.d.ts.map +1 -0
  27. package/dist/Listbox/Listbox.js +151 -0
  28. package/dist/Listbox/Listbox.js.map +1 -0
  29. package/dist/Listbox/ListboxOption.d.ts +36 -0
  30. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  31. package/dist/Listbox/ListboxOption.js +42 -0
  32. package/dist/Listbox/ListboxOption.js.map +1 -0
  33. package/dist/Menu/Menu.d.ts.map +1 -1
  34. package/dist/Menu/Menu.js +47 -97
  35. package/dist/Menu/Menu.js.map +1 -1
  36. package/dist/Modal/Modal.d.ts +11 -59
  37. package/dist/Modal/Modal.d.ts.map +1 -1
  38. package/dist/Modal/Modal.js +28 -101
  39. package/dist/Modal/Modal.js.map +1 -1
  40. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  41. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  42. package/dist/Overlay/OverlayPanel.js +72 -0
  43. package/dist/Overlay/OverlayPanel.js.map +1 -0
  44. package/dist/Overlay/OverlayStack.d.ts +47 -0
  45. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  46. package/dist/Overlay/OverlayStack.js +46 -0
  47. package/dist/Overlay/OverlayStack.js.map +1 -0
  48. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  49. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  50. package/dist/Overlay/SharedBackdrop.js +28 -0
  51. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  52. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  53. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  54. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  55. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  56. package/dist/Overlay/overlayPanelClass.js +17 -0
  57. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  58. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  59. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  60. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  61. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  62. package/dist/Popover/Popover.d.ts +16 -24
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +32 -68
  65. package/dist/Popover/Popover.js.map +1 -1
  66. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/Tooltip.js +25 -25
  68. package/dist/Tooltip/Tooltip.js.map +1 -1
  69. package/dist/index.d.ts +13 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +6 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/mountStory.testHelpers.d.ts +1 -0
  74. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  75. package/dist/mountStory.testHelpers.js +21 -1
  76. package/dist/mountStory.testHelpers.js.map +1 -1
  77. package/package.json +5 -4
  78. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  79. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  80. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  81. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  82. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  83. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  84. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  85. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  86. package/src/Combobox/Combobox.mdx +71 -0
  87. package/src/Combobox/Combobox.stories.tsx +245 -0
  88. package/src/Combobox/Combobox.test.tsx +289 -0
  89. package/src/Combobox/Combobox.tsx +513 -0
  90. package/src/Combobox/ComboboxOption.tsx +88 -0
  91. package/src/Dialog/Dialog.mdx +87 -0
  92. package/src/Dialog/Dialog.stories.tsx +347 -0
  93. package/src/Dialog/Dialog.test.tsx +312 -0
  94. package/src/Dialog/Dialog.tsx +127 -0
  95. package/src/Field/Field.test.tsx +6 -4
  96. package/src/Lightbox/Lightbox.mdx +63 -0
  97. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  98. package/src/Lightbox/Lightbox.test.tsx +124 -0
  99. package/src/Lightbox/Lightbox.tsx +143 -0
  100. package/src/Listbox/Listbox.mdx +68 -0
  101. package/src/Listbox/Listbox.stories.tsx +205 -0
  102. package/src/Listbox/Listbox.test.tsx +198 -0
  103. package/src/Listbox/Listbox.tsx +295 -0
  104. package/src/Listbox/ListboxOption.tsx +104 -0
  105. package/src/Menu/Menu.mdx +11 -13
  106. package/src/Menu/Menu.test.tsx +47 -38
  107. package/src/Menu/Menu.tsx +81 -142
  108. package/src/Modal/Modal.mdx +29 -82
  109. package/src/Modal/Modal.stories.tsx +55 -188
  110. package/src/Modal/Modal.test.tsx +44 -177
  111. package/src/Modal/Modal.tsx +54 -204
  112. package/src/Overlay/OverlayPanel.tsx +195 -0
  113. package/src/Overlay/OverlayStack.test.tsx +235 -0
  114. package/src/Overlay/OverlayStack.tsx +132 -0
  115. package/src/Overlay/SharedBackdrop.tsx +40 -0
  116. package/src/Overlay/overlayPanelClass.ts +17 -0
  117. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  118. package/src/Popover/Popover.mdx +17 -20
  119. package/src/Popover/Popover.stories.tsx +33 -0
  120. package/src/Popover/Popover.test.tsx +68 -32
  121. package/src/Popover/Popover.tsx +56 -95
  122. package/src/Tooltip/Tooltip.mdx +12 -9
  123. package/src/Tooltip/Tooltip.test.tsx +17 -19
  124. package/src/Tooltip/Tooltip.tsx +35 -32
  125. package/src/icons.storyHelpers.tsx +22 -0
  126. package/src/index.ts +22 -4
  127. package/src/mountStory.testHelpers.ts +25 -1
  128. package/src/sourceRules.test.ts +26 -1
  129. package/src/storyControls.test.ts +25 -12
  130. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  131. package/dist/Modal/lockScrollBehind.js.map +0 -1
  132. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  133. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  134. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -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
  })
@@ -1,228 +1,78 @@
1
- import { useUniqueId } from "@charcuterie/logic"
2
- import type {
3
- ComponentPropsWithRef,
4
- ReactNode,
5
- } from "react"
6
- import { useEffect, useRef } from "react"
1
+ import type { ReactNode } from "react"
7
2
 
8
- import { Button } from "../Button/Button.tsx"
3
+ import { OverlayPanel } from "../Overlay/OverlayPanel.tsx"
9
4
  import { toClassName } from "../toClassName.ts"
10
- import { lockScrollBehind } from "./lockScrollBehind.ts"
11
5
 
12
- export type ModalSize = "full" | "lg" | "md" | "sm" | "xl"
13
-
14
- export type ModalProps = Omit<
15
- ComponentPropsWithRef<"dialog">,
16
- "onClose" | "open"
17
- > & {
18
- /** The footer row. Buttons, usually. */
19
- footer?: ReactNode
20
- heading: string
21
- headingLevel?: 2 | 3 | 4
22
- /**
23
- * Escape and a backdrop press both close. Off for the one
24
- * honest case — a destructive confirm the user has to answer —
25
- * and off means Escape is *cancelled* rather than ignored, so
26
- * the browser cannot close a dialog the app still owns.
27
- */
6
+ export type ModalProps = {
7
+ "aria-label"?: string
8
+ "aria-labelledby"?: string
9
+ children: ReactNode
10
+ className?: string
11
+ /** Escape and a backdrop press both close. */
28
12
  isDismissable?: boolean
29
13
  isVisible: boolean
30
14
  /**
31
- * Every route out lands here: the close button, Escape, a
32
- * backdrop press. Wire it to a `useVisibility()`'s `hide`.
15
+ * Every route out lands here: Escape, a backdrop press. Wire it to a
16
+ * `useVisibility()`'s `hide`.
33
17
  */
34
18
  onClose: () => void
35
- size?: ModalSize
19
+ role?: "alertdialog" | "dialog"
36
20
  }
37
21
 
38
22
  /**
39
- * `85dvh` rather than `85vh` — on a phone the two differ by the
40
- * address bar, and a dialog whose footer is under it is a dialog
41
- * with no buttons.
42
- */
43
- const SIZE_CLASS: Record<ModalSize, string> = {
44
- sm: "max-h-[85dvh] w-full max-w-sm",
45
- md: "max-h-[85dvh] w-full max-w-md",
46
- lg: "max-h-[85dvh] w-full max-w-2xl",
47
- // M5 added this, because the scale jumped from 42rem straight to
48
- // the whole viewport and rip-deck's capture tail lands in the
49
- // gap: a MakeMKV robot log is fixed-width `MSG:5072,0,1,"…"`
50
- // lines that wrap badly at 42rem, and a full-screen dialog over a
51
- // nine-bay dashboard hides the thing being diagnosed. 56rem is
52
- // what that app's hand-rolled `<dialog>` used before the swap.
53
- xl: "max-h-[85dvh] w-full max-w-4xl",
54
- full: "h-[100dvh] max-h-[100dvh] w-full max-w-full rounded-none",
55
- }
56
-
57
- /**
58
- * Four repos, and **plex-channels and ripdeck independently
59
- * converged on native `<dialog>`** — which is the design here
60
- * rather than a shortcut. `showModal()` gives a focus trap,
61
- * Escape, `::backdrop`, `inert` on everything behind it, and the
62
- * **top layer**, from the platform, correctly, in every browser
63
- * the fleet runs.
64
- *
65
- * What is not free — and the reason a component exists at all —
66
- * is that a native `<dialog>` is a *self-owning* control: call
67
- * `showModal()` and it decides it is open. That is the same
68
- * conflict Radix has, and it gets the same answer as everywhere
69
- * else in this library: **`isVisible` is the truth, and an effect
70
- * makes the element agree with it.** Nothing here reads
71
- * `dialogElement.open` as state; it is read only to avoid calling
72
- * a method that would throw.
23
+ * The base layer. Backdrop, dismiss, and portal — and no chrome.
73
24
  *
74
- * ### Why it is not portalled
25
+ * This is the generic modal the library never had while `Modal` was
26
+ * secretly a *dialog*. A dialog is one kind of modal; so are a
27
+ * confirmation, a media lightbox, and anything else that wants a
28
+ * scrim, an Escape, and a click-outside. `Modal` is that foundation,
29
+ * and `Dialog` is `Modal` plus the heading/Close/footer/size chrome
30
+ * the old component shipped.
75
31
  *
76
- * The top layer is a *paint* concept: a `<dialog>` renders above
77
- * everything while staying exactly where it is in the DOM. So it
78
- * needs no portal — and that matters well past tidiness, because a
79
- * portal moves the node to `document.body`, out of whatever
80
- * element a test or an agent scoped its queries to.
81
- * `canvas.getByRole("dialog")` finds this one. It would not find a
82
- * portalled one, and the story would have to reach for `screen`
83
- * and lose its scoping.
32
+ * It renders an `OverlayPanel`: portalled to `document.body` (escaping
33
+ * `overflow: hidden` the top layer could not), focus-trapped, stacked
34
+ * by portal append order, and dismissible only when it is the top of
35
+ * the stack. All `Modal` adds is a sensible surface — a bordered,
36
+ * rounded, elevated box clamped to `85dvh` — that a caller composes
37
+ * over with `className`. `Dialog` replaces the width and layout; a
38
+ * bare `Modal` sizes to its content.
84
39
  *
85
- * ### `open:flex`, not `flex`
40
+ * ### One accessible name, required
86
41
  *
87
- * A closed `<dialog>` is hidden by a UA rule that any
88
- * `display: flex` of ours would beat — so the modal would render
89
- * permanently, in the page flow, with no error. The layout is
90
- * therefore behind Tailwind's `open:` variant, which is the
91
- * `[open]` attribute the browser manages.
42
+ * A `role="dialog"` takes no name from its content, so pass exactly
43
+ * one of `aria-label` / `aria-labelledby`. Missing both is a dev-time
44
+ * warning from `OverlayPanel`, not a throw — the lesson `Spinner`,
45
+ * `Popover`, and the old `Modal` each learned.
92
46
  *
93
- * ### The accessible name
47
+ * ### Stacking is zero-config, until it is not
94
48
  *
95
- * `heading` is required and is a string, not a `ReactNode`. A
96
- * dialog with no name is announced as "dialog" and nothing else,
97
- * which is what every hand-rolled modal in the inventory does.
49
+ * A lone `Modal` renders its own scrim with no setup. Only when modals
50
+ * **stack** — one opened from inside another — does the app need an
51
+ * `OverlayStackProvider` at its root, so the N of them share one scrim
52
+ * and dismiss top-first.
98
53
  */
54
+ const MODAL_BOX_CLASS =
55
+ "flex max-h-[85dvh] flex-col overflow-hidden rounded-lg border border-border-default bg-surface-overlay text-content-primary shadow-high"
56
+
99
57
  export const Modal = ({
58
+ "aria-label": ariaLabel,
59
+ "aria-labelledby": ariaLabelledBy,
100
60
  children,
101
61
  className,
102
- footer,
103
- heading,
104
- headingLevel = 2,
105
62
  isDismissable = true,
106
63
  isVisible,
107
64
  onClose,
108
- size = "md",
109
- ...dialogProps
110
- }: ModalProps): ReactNode => {
111
- const dialogRef = useRef<HTMLDialogElement>(null)
112
-
113
- const headingId = useUniqueId()
114
-
115
- const Heading = `h${headingLevel}` as const
116
-
117
- useEffect(() => {
118
- const dialogElement = dialogRef.current
119
-
120
- if (!dialogElement) {
121
- return
122
- }
123
-
124
- // Guarded both ways. `showModal()` on an already-open dialog
125
- // throws `InvalidStateError`, and `close()` on a closed one
126
- // fires a spurious `close` event — either turns a re-render
127
- // into a bug.
128
- if (isVisible && !dialogElement.open) {
129
- dialogElement.showModal()
130
- }
131
-
132
- if (!isVisible && dialogElement.open) {
133
- dialogElement.close()
134
- }
135
- }, [isVisible])
136
-
137
- useEffect(
138
- () => (isVisible ? lockScrollBehind() : undefined),
139
- [isVisible],
140
- )
141
-
142
- return (
143
- /*
144
- * The one suppression in this package, and the rule really is
145
- * wrong here rather than inconvenient.
146
- *
147
- * `useKeyWithClickEvents` looks for a click handler on
148
- * something a keyboard user cannot reach. This one is on the
149
- * **backdrop** — which is not an element, has no keyboard
150
- * equivalent to give it, and whose keyboard counterpart is the
151
- * `onCancel` directly above: Escape, from the platform, which
152
- * is the entire reason this is built on `<dialog>`. Biome
153
- * cannot see `onCancel` as a keyboard route.
154
- *
155
- * Pairing the click with an `onKeyDown` to satisfy the rule
156
- * would duplicate a close request the browser already routes
157
- * correctly — including deciding which of two stacked dialogs
158
- * it belongs to — which is a worse component in exchange for a
159
- * green linter.
160
- */
161
- // biome-ignore lint/a11y/useKeyWithClickEvents: Escape is handled by `onCancel`, which this rule cannot see.
162
- <dialog
163
- {...dialogProps}
164
- aria-labelledby={headingId}
165
- className={toClassName(
166
- // `m-auto` and `p-0` undo the UA stylesheet, which centres
167
- // by margin and adds padding of its own.
168
- "m-auto flex-col overflow-hidden rounded-lg border border-border-default bg-surface-overlay p-0 text-content-primary shadow-high backdrop:bg-scrim open:flex",
169
- SIZE_CLASS[size],
170
- className,
171
- )}
172
- onCancel={(cancelEvent) => {
173
- // Escape. Cancelled rather than ignored when the dialog is
174
- // not dismissable: letting the browser close it would
175
- // leave `isVisible` true and the element shut, which is
176
- // precisely the desync this component exists to prevent.
177
- cancelEvent.preventDefault()
178
-
179
- if (isDismissable) {
180
- onClose()
181
- }
182
- }}
183
- onClick={(clickEvent) => {
184
- // The backdrop is not an element — a press on it targets
185
- // the `<dialog>` itself, while a press on the content
186
- // targets something inside. Comparing target to
187
- // currentTarget is the whole test.
188
- if (
189
- isDismissable &&
190
- clickEvent.target === clickEvent.currentTarget
191
- ) {
192
- onClose()
193
- }
194
- }}
195
- ref={dialogRef}
196
- >
197
- <header className="flex shrink-0 items-start justify-between gap-3 border-b border-border-subtle p-4">
198
- <Heading
199
- className="font-semibold text-lg leading-tight"
200
- id={headingId}
201
- >
202
- {heading}
203
- </Heading>
204
-
205
- {isDismissable ? (
206
- <Button
207
- appearance="ghost"
208
- intent="neutral"
209
- onClick={onClose}
210
- size="sm"
211
- >
212
- Close
213
- </Button>
214
- ) : null}
215
- </header>
216
-
217
- <div className="min-h-0 flex-1 overflow-auto p-4">
218
- {children}
219
- </div>
220
-
221
- {footer ? (
222
- <footer className="flex shrink-0 flex-wrap justify-end gap-2 border-t border-border-subtle p-4">
223
- {footer}
224
- </footer>
225
- ) : null}
226
- </dialog>
227
- )
228
- }
65
+ role = "dialog",
66
+ }: ModalProps): ReactNode => (
67
+ <OverlayPanel
68
+ aria-label={ariaLabel}
69
+ aria-labelledby={ariaLabelledBy}
70
+ className={toClassName(MODAL_BOX_CLASS, className)}
71
+ isDismissable={isDismissable}
72
+ isVisible={isVisible}
73
+ onClose={onClose}
74
+ role={role}
75
+ >
76
+ {children}
77
+ </OverlayPanel>
78
+ )