@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.
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
- 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 +57 -0
- package/dist/Lightbox/Lightbox.d.ts.map +1 -0
- package/dist/Lightbox/Lightbox.js +50 -0
- package/dist/Lightbox/Lightbox.js.map +1 -0
- 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 +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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 +5 -4
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
- 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 +63 -0
- package/src/Lightbox/Lightbox.stories.tsx +133 -0
- package/src/Lightbox/Lightbox.test.tsx +124 -0
- package/src/Lightbox/Lightbox.tsx +143 -0
- 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/icons.storyHelpers.tsx +22 -0
- package/src/index.ts +22 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +26 -1
- 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
package/src/Modal/Modal.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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: "
|
|
19
|
+
name: "Open the base modal",
|
|
49
20
|
role: "button",
|
|
50
21
|
}),
|
|
51
22
|
)
|
|
52
23
|
|
|
53
|
-
|
|
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
|
-
|
|
29
|
+
// Portalled out of the canvas — the whole point over the top layer.
|
|
30
|
+
await expect(canvasElement).not.toContainElement(modal)
|
|
62
31
|
|
|
63
|
-
|
|
64
|
-
// so the *open* dialog audits itself.
|
|
65
|
-
await expectNoAxeViolations(canvasElement)
|
|
32
|
+
await expectNoAxeViolations(modal)
|
|
66
33
|
})
|
|
67
34
|
|
|
68
|
-
test("
|
|
69
|
-
const { canvas } = await mountStory(
|
|
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
|
|
40
|
+
name: "Open an alertdialog",
|
|
74
41
|
role: "button",
|
|
75
42
|
}),
|
|
76
43
|
)
|
|
77
44
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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("
|
|
89
|
-
const { canvas } = await mountStory(
|
|
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
|
|
99
|
-
name: "
|
|
100
|
-
role: "
|
|
55
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
56
|
+
name: "Open the base modal",
|
|
57
|
+
role: "button",
|
|
101
58
|
})
|
|
102
59
|
|
|
103
|
-
|
|
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
|
-
|
|
62
|
+
expectAgentDrivable(body, {
|
|
63
|
+
name: "Read error on title 4",
|
|
64
|
+
role: "dialog",
|
|
65
|
+
})
|
|
122
66
|
|
|
123
|
-
|
|
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(
|
|
70
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
130
71
|
})
|
|
131
|
-
})
|
|
132
72
|
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
role: "dialog",
|
|
76
|
+
await waitFor(() => {
|
|
77
|
+
expect(body.getByRole("dialog")).toBeInTheDocument()
|
|
144
78
|
})
|
|
145
79
|
|
|
146
|
-
|
|
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
|
|
153
|
-
|
|
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
|
|
97
|
+
const modal = expectAgentDrivable(body, {
|
|
229
98
|
name: "Stop the rip?",
|
|
230
99
|
role: "dialog",
|
|
231
100
|
})
|
|
232
101
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(modal.contains(document.activeElement)).toBe(
|
|
104
|
+
true,
|
|
105
|
+
)
|
|
106
|
+
})
|
|
238
107
|
|
|
239
|
-
|
|
108
|
+
await userEvent.keyboard("{Escape}")
|
|
240
109
|
|
|
241
110
|
await waitFor(() => {
|
|
242
|
-
expect(
|
|
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
|
})
|
package/src/Modal/Modal.tsx
CHANGED
|
@@ -1,228 +1,78 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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:
|
|
32
|
-
*
|
|
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
|
-
|
|
19
|
+
role?: "alertdialog" | "dialog"
|
|
36
20
|
}
|
|
37
21
|
|
|
38
22
|
/**
|
|
39
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* `
|
|
82
|
-
*
|
|
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
|
-
* ###
|
|
40
|
+
* ### One accessible name, required
|
|
86
41
|
*
|
|
87
|
-
* A
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
* ###
|
|
47
|
+
* ### Stacking is zero-config, until it is not
|
|
94
48
|
*
|
|
95
|
-
* `
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
)
|