@charcuterie/ui 1.1.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Combobox/Combobox.d.ts +54 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -0
- package/dist/Combobox/Combobox.js +216 -0
- package/dist/Combobox/Combobox.js.map +1 -0
- package/dist/Combobox/ComboboxOption.d.ts +36 -0
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
- package/dist/Combobox/ComboboxOption.js +32 -0
- package/dist/Combobox/ComboboxOption.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +53 -0
- package/dist/Dialog/Dialog.d.ts.map +1 -0
- package/dist/Dialog/Dialog.js +52 -0
- package/dist/Dialog/Dialog.js.map +1 -0
- package/dist/Lightbox/Lightbox.d.ts +7 -7
- package/dist/Lightbox/Lightbox.js +9 -9
- package/dist/Lightbox/Lightbox.js.map +1 -1
- package/dist/Listbox/Listbox.d.ts +56 -0
- package/dist/Listbox/Listbox.d.ts.map +1 -0
- package/dist/Listbox/Listbox.js +151 -0
- package/dist/Listbox/Listbox.js.map +1 -0
- package/dist/Listbox/ListboxOption.d.ts +36 -0
- package/dist/Listbox/ListboxOption.d.ts.map +1 -0
- package/dist/Listbox/ListboxOption.js +42 -0
- package/dist/Listbox/ListboxOption.js.map +1 -0
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +47 -97
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Modal/Modal.d.ts +11 -59
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +28 -101
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +50 -0
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
- package/dist/Overlay/OverlayPanel.js +72 -0
- package/dist/Overlay/OverlayPanel.js.map +1 -0
- package/dist/Overlay/OverlayStack.d.ts +47 -0
- package/dist/Overlay/OverlayStack.d.ts.map +1 -0
- package/dist/Overlay/OverlayStack.js +46 -0
- package/dist/Overlay/OverlayStack.js.map +1 -0
- package/dist/Overlay/SharedBackdrop.d.ts +24 -0
- package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
- package/dist/Overlay/SharedBackdrop.js +28 -0
- package/dist/Overlay/SharedBackdrop.js.map +1 -0
- package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
- package/dist/Overlay/lockScrollBehind.js.map +1 -0
- package/dist/Overlay/overlayPanelClass.d.ts +17 -0
- package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
- package/dist/Overlay/overlayPanelClass.js +17 -0
- package/dist/Overlay/overlayPanelClass.js.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.js +81 -0
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
- package/dist/Popover/Popover.d.ts +16 -24
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +32 -68
- package/dist/Popover/Popover.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +25 -25
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +1 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +21 -1
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +3 -2
- package/src/Combobox/Combobox.mdx +71 -0
- package/src/Combobox/Combobox.stories.tsx +245 -0
- package/src/Combobox/Combobox.test.tsx +289 -0
- package/src/Combobox/Combobox.tsx +513 -0
- package/src/Combobox/ComboboxOption.tsx +88 -0
- package/src/Dialog/Dialog.mdx +87 -0
- package/src/Dialog/Dialog.stories.tsx +397 -0
- package/src/Dialog/Dialog.test.tsx +340 -0
- package/src/Dialog/Dialog.tsx +133 -0
- package/src/Field/Field.test.tsx +6 -4
- package/src/Lightbox/Lightbox.mdx +9 -8
- package/src/Lightbox/Lightbox.stories.tsx +5 -5
- package/src/Lightbox/Lightbox.test.tsx +25 -36
- package/src/Lightbox/Lightbox.tsx +10 -10
- package/src/Listbox/Listbox.mdx +68 -0
- package/src/Listbox/Listbox.stories.tsx +205 -0
- package/src/Listbox/Listbox.test.tsx +198 -0
- package/src/Listbox/Listbox.tsx +295 -0
- package/src/Listbox/ListboxOption.tsx +104 -0
- package/src/Menu/Menu.mdx +11 -13
- package/src/Menu/Menu.test.tsx +47 -38
- package/src/Menu/Menu.tsx +81 -142
- package/src/Modal/Modal.mdx +29 -82
- package/src/Modal/Modal.stories.tsx +55 -188
- package/src/Modal/Modal.test.tsx +44 -177
- package/src/Modal/Modal.tsx +54 -204
- package/src/Overlay/OverlayPanel.tsx +195 -0
- package/src/Overlay/OverlayStack.test.tsx +235 -0
- package/src/Overlay/OverlayStack.tsx +132 -0
- package/src/Overlay/SharedBackdrop.tsx +40 -0
- package/src/Overlay/overlayPanelClass.ts +17 -0
- package/src/Overlay/useAnchoredOverlay.ts +197 -0
- package/src/Popover/Popover.mdx +17 -20
- package/src/Popover/Popover.stories.tsx +33 -0
- package/src/Popover/Popover.test.tsx +68 -32
- package/src/Popover/Popover.tsx +56 -95
- package/src/Tooltip/Tooltip.mdx +12 -9
- package/src/Tooltip/Tooltip.test.tsx +17 -19
- package/src/Tooltip/Tooltip.tsx +35 -32
- package/src/index.ts +13 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +12 -3
- package/src/storyControls.test.ts +25 -12
- package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
- package/dist/Modal/lockScrollBehind.js.map +0 -1
- /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
- /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
- /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
|
@@ -0,0 +1,340 @@
|
|
|
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
|
+
NoBody,
|
|
19
|
+
Responsive,
|
|
20
|
+
Stacked,
|
|
21
|
+
} = composeStories(stories)
|
|
22
|
+
|
|
23
|
+
test("a dialog with no body renders from heading and footer alone", async () => {
|
|
24
|
+
const { body, canvas } = await mountStory(NoBody)
|
|
25
|
+
|
|
26
|
+
await userEvent.click(
|
|
27
|
+
expectAgentDrivable(canvas, {
|
|
28
|
+
name: "Delete the file",
|
|
29
|
+
role: "button",
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// `children` is optional: the question is the accessible name and
|
|
34
|
+
// the answers are the footer, with nothing in between.
|
|
35
|
+
const dialog = expectAgentDrivable(body, {
|
|
36
|
+
name: "Delete this file? This cannot be undone.",
|
|
37
|
+
role: "dialog",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
await expect(
|
|
41
|
+
expectAgentDrivable(body, {
|
|
42
|
+
name: "Delete",
|
|
43
|
+
role: "button",
|
|
44
|
+
}),
|
|
45
|
+
).toBeInTheDocument()
|
|
46
|
+
|
|
47
|
+
await expectNoAxeViolations(dialog)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("the dialog is reachable from a body-scoped query", async () => {
|
|
51
|
+
const { body, canvas } = await mountStory(Default)
|
|
52
|
+
|
|
53
|
+
await userEvent.click(
|
|
54
|
+
expectAgentDrivable(canvas, {
|
|
55
|
+
name: "Show the read error",
|
|
56
|
+
role: "button",
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Portalled now, so the panel is found through `body`; the trigger
|
|
61
|
+
// stays in the canvas.
|
|
62
|
+
const dialog = expectAgentDrivable(body, {
|
|
63
|
+
name: "Read error on title 4",
|
|
64
|
+
role: "dialog",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await expectNoAxeViolations(dialog)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("size is a real width, not a class that happens to be there", async () => {
|
|
71
|
+
const { body, canvas } = await mountStory(AllVariants)
|
|
72
|
+
|
|
73
|
+
await userEvent.click(
|
|
74
|
+
expectAgentDrivable(canvas, {
|
|
75
|
+
name: "Open lg",
|
|
76
|
+
role: "button",
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const dialog = expectAgentDrivable(body, {
|
|
81
|
+
name: "Read error — lg",
|
|
82
|
+
role: "dialog",
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
dialog.getBoundingClientRect().width,
|
|
87
|
+
).toBeGreaterThan(400)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("a non-dismissable dialog ignores Escape rather than closing", async () => {
|
|
91
|
+
const { body, canvas } = await mountStory(AllStates)
|
|
92
|
+
|
|
93
|
+
await userEvent.click(
|
|
94
|
+
expectAgentDrivable(canvas, {
|
|
95
|
+
name: "Not dismissable",
|
|
96
|
+
role: "button",
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const _dialog = expectAgentDrivable(body, {
|
|
101
|
+
name: "This erases eight completed titles",
|
|
102
|
+
role: "dialog",
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// There is no Close button, and Escape is disabled at the dismiss
|
|
106
|
+
// layer (`isDismissable={false}` → `useDismiss` escapeKey off), so
|
|
107
|
+
// the browser cannot close a dialog the app still owns.
|
|
108
|
+
await expect(
|
|
109
|
+
body.queryByRole("button", { name: "Close" }),
|
|
110
|
+
).toBeNull()
|
|
111
|
+
|
|
112
|
+
await userEvent.keyboard("{Escape}")
|
|
113
|
+
|
|
114
|
+
const keepButton = await body.findByRole("button", {
|
|
115
|
+
name: "Keep the titles",
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
await expect(body.getByRole("dialog")).toBeInTheDocument()
|
|
119
|
+
|
|
120
|
+
// The differential half. Without it, "still open" would also be
|
|
121
|
+
// satisfied by a dialog nothing can close at all.
|
|
122
|
+
await userEvent.click(keepButton)
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test("a long body scrolls inside the clamp", async () => {
|
|
130
|
+
const { body, canvas } = await mountStory(AllStates)
|
|
131
|
+
|
|
132
|
+
await userEvent.click(
|
|
133
|
+
canvas.getByRole("button", { name: "Long body" }),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const log = expectAgentDrivable(body, {
|
|
137
|
+
name: "MakeMKV log",
|
|
138
|
+
role: "dialog",
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// The body scrolls, the header and footer do not, and the dialog
|
|
142
|
+
// stops at `85dvh` rather than running off the screen.
|
|
143
|
+
await expect(
|
|
144
|
+
log.getBoundingClientRect().height,
|
|
145
|
+
).toBeLessThan(globalThis.innerHeight)
|
|
146
|
+
|
|
147
|
+
await expect(
|
|
148
|
+
body.getByText(/title 24/),
|
|
149
|
+
).toBeInTheDocument()
|
|
150
|
+
|
|
151
|
+
await expectNoAxeViolations(log)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("the scrim is the token, rendered as the shared backdrop", async () => {
|
|
155
|
+
const { body, canvas } = await mountStory(Responsive)
|
|
156
|
+
|
|
157
|
+
await userEvent.click(
|
|
158
|
+
expectAgentDrivable(canvas, {
|
|
159
|
+
name: "Open full-bleed",
|
|
160
|
+
role: "button",
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
const dialog = expectAgentDrivable(body, {
|
|
165
|
+
name: "MakeMKV log — full",
|
|
166
|
+
role: "dialog",
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
dialog.getBoundingClientRect().height,
|
|
171
|
+
).toBeGreaterThan(globalThis.innerHeight * 0.95)
|
|
172
|
+
|
|
173
|
+
// The shared backdrop node renders while a modal is open, and its
|
|
174
|
+
// background really resolves the variant token — not "not
|
|
175
|
+
// transparent", which M4 learned stayed green against a token build
|
|
176
|
+
// where `bg-scrim` generated no CSS at all.
|
|
177
|
+
const scrim = document.body.querySelector(".bg-scrim")
|
|
178
|
+
|
|
179
|
+
await expect(scrim).not.toBeNull()
|
|
180
|
+
|
|
181
|
+
const probe = document.createElement("div")
|
|
182
|
+
|
|
183
|
+
probe.style.backgroundColor = "var(--color-scrim)"
|
|
184
|
+
|
|
185
|
+
document.body.append(probe)
|
|
186
|
+
|
|
187
|
+
const expectedScrim =
|
|
188
|
+
globalThis.getComputedStyle(probe).backgroundColor
|
|
189
|
+
|
|
190
|
+
probe.remove()
|
|
191
|
+
|
|
192
|
+
await expect(expectedScrim).not.toBe("rgba(0, 0, 0, 0)")
|
|
193
|
+
|
|
194
|
+
await expect(
|
|
195
|
+
globalThis.getComputedStyle(scrim as Element)
|
|
196
|
+
.backgroundColor,
|
|
197
|
+
).toBe(expectedScrim)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The keyboard contract, driven — and Escape is pressable for real
|
|
202
|
+
* now, unlike the old native `<dialog>`. `useDismiss` listens for a
|
|
203
|
+
* keydown rather than relying on a UA default action, so
|
|
204
|
+
* `userEvent.keyboard("{Escape}")` is a real close request.
|
|
205
|
+
*/
|
|
206
|
+
test("focus is trapped inside, and Escape restores it to the trigger", async () => {
|
|
207
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
208
|
+
|
|
209
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
210
|
+
name: "Stop the rip",
|
|
211
|
+
role: "button",
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
await userEvent.click(trigger)
|
|
215
|
+
|
|
216
|
+
const dialog = expectAgentDrivable(body, {
|
|
217
|
+
name: "Stop the rip?",
|
|
218
|
+
role: "dialog",
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
expect(dialog.contains(document.activeElement)).toBe(
|
|
223
|
+
true,
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await userEvent.keyboard("{Escape}")
|
|
228
|
+
|
|
229
|
+
await waitFor(() => {
|
|
230
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Focus restored to what opened it — `FloatingFocusManager`'s
|
|
234
|
+
// `returnFocus`, the thing hand-rolled modals in the fleet lose.
|
|
235
|
+
await expect(document.activeElement).toBe(trigger)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Stacking: one scrim behind two dialogs, the lower one `inert`, and
|
|
240
|
+
* a top-first dismissal on both an outside press and Escape.
|
|
241
|
+
*/
|
|
242
|
+
test("stacked dialogs share one scrim and dismiss top-first", async () => {
|
|
243
|
+
const { body, canvas } = await mountStory(Stacked)
|
|
244
|
+
|
|
245
|
+
await userEvent.click(
|
|
246
|
+
expectAgentDrivable(canvas, {
|
|
247
|
+
name: "Open the first dialog",
|
|
248
|
+
role: "button",
|
|
249
|
+
}),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
// Captured while it is still the only dialog: once the second opens,
|
|
253
|
+
// the first goes `inert` and a role query no longer sees it.
|
|
254
|
+
const first = expectAgentDrivable(body, {
|
|
255
|
+
name: "First dialog",
|
|
256
|
+
role: "dialog",
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await userEvent.click(
|
|
260
|
+
expectAgentDrivable(body, {
|
|
261
|
+
name: "Open a second over it",
|
|
262
|
+
role: "button",
|
|
263
|
+
}),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const second = expectAgentDrivable(body, {
|
|
267
|
+
name: "Second dialog",
|
|
268
|
+
role: "dialog",
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// One scrim for both — the whole point of the provider.
|
|
272
|
+
await expect(
|
|
273
|
+
document.body.querySelectorAll(".bg-scrim"),
|
|
274
|
+
).toHaveLength(1)
|
|
275
|
+
|
|
276
|
+
// The lower dialog is hidden from assistive tech while the top is
|
|
277
|
+
// open, and focus is trapped in the top.
|
|
278
|
+
expectHiddenFromAgents(first)
|
|
279
|
+
|
|
280
|
+
await waitFor(() => {
|
|
281
|
+
expect(second.contains(document.activeElement)).toBe(
|
|
282
|
+
true,
|
|
283
|
+
)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// The top dialog's own controls are actually **hittable** — the
|
|
287
|
+
// scrim sits a layer below the panels, so it does not intercept a
|
|
288
|
+
// real click. `userEvent` fires on the element directly and would
|
|
289
|
+
// miss this; `elementFromPoint` is the check that a covering scrim
|
|
290
|
+
// fails.
|
|
291
|
+
const closeButton = expectAgentDrivable(body, {
|
|
292
|
+
name: "Close this one",
|
|
293
|
+
role: "button",
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const buttonRect = closeButton.getBoundingClientRect()
|
|
297
|
+
|
|
298
|
+
const atPoint = document.elementFromPoint(
|
|
299
|
+
buttonRect.left + buttonRect.width / 2,
|
|
300
|
+
buttonRect.top + buttonRect.height / 2,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
await expect(
|
|
304
|
+
atPoint !== null && closeButton.contains(atPoint),
|
|
305
|
+
).toBe(true)
|
|
306
|
+
|
|
307
|
+
// An outside press closes the top only.
|
|
308
|
+
await userEvent.click(document.body)
|
|
309
|
+
|
|
310
|
+
await waitFor(() => {
|
|
311
|
+
expect(
|
|
312
|
+
body.queryByRole("dialog", {
|
|
313
|
+
name: "Second dialog",
|
|
314
|
+
}),
|
|
315
|
+
).toBeNull()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// The first is live again — no longer inert, and the scrim stays.
|
|
319
|
+
await expect(
|
|
320
|
+
document.body.querySelectorAll(".bg-scrim"),
|
|
321
|
+
).toHaveLength(1)
|
|
322
|
+
|
|
323
|
+
const firstAgain = expectAgentDrivable(body, {
|
|
324
|
+
name: "First dialog",
|
|
325
|
+
role: "dialog",
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Escape closes the last one, and the scrim goes with it.
|
|
329
|
+
await userEvent.keyboard("{Escape}")
|
|
330
|
+
|
|
331
|
+
await waitFor(() => {
|
|
332
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
await expect(firstAgain).not.toBeInTheDocument()
|
|
336
|
+
|
|
337
|
+
await expect(
|
|
338
|
+
document.body.querySelector(".bg-scrim"),
|
|
339
|
+
).toBeNull()
|
|
340
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { Button } from "../Button/Button.tsx"
|
|
5
|
+
import { Modal } from "../Modal/Modal.tsx"
|
|
6
|
+
import { toClassName } from "../toClassName.ts"
|
|
7
|
+
|
|
8
|
+
export type DialogSize = "full" | "lg" | "md" | "sm" | "xl"
|
|
9
|
+
|
|
10
|
+
export type DialogProps = {
|
|
11
|
+
/**
|
|
12
|
+
* The scrolling body. Optional: a confirm dialog whose question is
|
|
13
|
+
* its `heading` and whose answers are its `footer` has no body, and
|
|
14
|
+
* the old chrome `Modal` (which extended `<dialog>`'s DOM props)
|
|
15
|
+
* allowed that — `Dialog` keeps it.
|
|
16
|
+
*/
|
|
17
|
+
children?: ReactNode
|
|
18
|
+
className?: string
|
|
19
|
+
/** The footer row. Buttons, usually. */
|
|
20
|
+
footer?: ReactNode
|
|
21
|
+
heading: string
|
|
22
|
+
headingLevel?: 2 | 3 | 4
|
|
23
|
+
/**
|
|
24
|
+
* Escape and a backdrop press both close. Off for the one honest
|
|
25
|
+
* case — a destructive confirm the user has to answer.
|
|
26
|
+
*/
|
|
27
|
+
isDismissable?: boolean
|
|
28
|
+
isVisible: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Every route out lands here: the close button, Escape, a backdrop
|
|
31
|
+
* press. Wire it to a `useVisibility()`'s `hide`.
|
|
32
|
+
*/
|
|
33
|
+
onClose: () => void
|
|
34
|
+
size?: DialogSize
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `85dvh` rather than `85vh` — on a phone the two differ by the
|
|
39
|
+
* address bar, and a dialog whose footer is under it is a dialog
|
|
40
|
+
* with no buttons.
|
|
41
|
+
*/
|
|
42
|
+
const SIZE_CLASS: Record<DialogSize, string> = {
|
|
43
|
+
sm: "max-h-[85dvh] w-full max-w-sm",
|
|
44
|
+
md: "max-h-[85dvh] w-full max-w-md",
|
|
45
|
+
lg: "max-h-[85dvh] w-full max-w-2xl",
|
|
46
|
+
// M5 added this, because the scale jumped from 42rem straight to
|
|
47
|
+
// the whole viewport and rip-deck's capture tail lands in the
|
|
48
|
+
// gap: a MakeMKV robot log is fixed-width `MSG:5072,0,1,"…"`
|
|
49
|
+
// lines that wrap badly at 42rem, and a full-screen dialog over a
|
|
50
|
+
// nine-bay dashboard hides the thing being diagnosed. 56rem is
|
|
51
|
+
// what that app's hand-rolled `<dialog>` used before the swap.
|
|
52
|
+
xl: "max-h-[85dvh] w-full max-w-4xl",
|
|
53
|
+
full: "h-[100dvh] max-h-[100dvh] w-full max-w-full rounded-none",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `Modal` plus the chrome — heading, a Close button, a scrolling body,
|
|
58
|
+
* an optional footer, and five sizes. This is what `Modal` *was*
|
|
59
|
+
* before the M8 rebuild renamed it: a dialog is one kind of modal, so
|
|
60
|
+
* it takes the specific name and hands the generic one back to the
|
|
61
|
+
* base layer.
|
|
62
|
+
*
|
|
63
|
+
* Everything modal — the portal, the scrim, Escape and outside-press
|
|
64
|
+
* dismiss, the focus trap, the scroll lock, the stacking — is `Modal`'s
|
|
65
|
+
* (via `OverlayPanel`), so this component is just the box's contents
|
|
66
|
+
* and the size class it wants that box to be. `SIZE_CLASS` is composed
|
|
67
|
+
* over `Modal`'s default surface, which is why `full` carries
|
|
68
|
+
* `rounded-none` to square off the base's `rounded-lg` at the viewport
|
|
69
|
+
* edge.
|
|
70
|
+
*
|
|
71
|
+
* ### The accessible name
|
|
72
|
+
*
|
|
73
|
+
* `heading` is required and is a string, not a `ReactNode`. It names
|
|
74
|
+
* both the visible header and — through `aria-labelledby` on the
|
|
75
|
+
* modal — the dialog itself. A dialog with no name is announced as
|
|
76
|
+
* "dialog" and nothing else, which is what every hand-rolled modal in
|
|
77
|
+
* the inventory does.
|
|
78
|
+
*/
|
|
79
|
+
export const Dialog = ({
|
|
80
|
+
children,
|
|
81
|
+
className,
|
|
82
|
+
footer,
|
|
83
|
+
heading,
|
|
84
|
+
headingLevel = 2,
|
|
85
|
+
isDismissable = true,
|
|
86
|
+
isVisible,
|
|
87
|
+
onClose,
|
|
88
|
+
size = "md",
|
|
89
|
+
}: DialogProps): ReactNode => {
|
|
90
|
+
const headingId = useUniqueId()
|
|
91
|
+
|
|
92
|
+
const Heading = `h${headingLevel}` as const
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Modal
|
|
96
|
+
aria-labelledby={headingId}
|
|
97
|
+
className={toClassName(SIZE_CLASS[size], className)}
|
|
98
|
+
isDismissable={isDismissable}
|
|
99
|
+
isVisible={isVisible}
|
|
100
|
+
onClose={onClose}
|
|
101
|
+
>
|
|
102
|
+
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border-subtle p-4">
|
|
103
|
+
<Heading
|
|
104
|
+
className="font-semibold text-lg leading-tight"
|
|
105
|
+
id={headingId}
|
|
106
|
+
>
|
|
107
|
+
{heading}
|
|
108
|
+
</Heading>
|
|
109
|
+
|
|
110
|
+
{isDismissable ? (
|
|
111
|
+
<Button
|
|
112
|
+
appearance="ghost"
|
|
113
|
+
intent="neutral"
|
|
114
|
+
onClick={onClose}
|
|
115
|
+
size="sm"
|
|
116
|
+
>
|
|
117
|
+
Close
|
|
118
|
+
</Button>
|
|
119
|
+
) : null}
|
|
120
|
+
</header>
|
|
121
|
+
|
|
122
|
+
<div className="min-h-0 flex-1 overflow-auto p-4">
|
|
123
|
+
{children}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{footer ? (
|
|
127
|
+
<footer className="flex shrink-0 flex-wrap justify-end gap-2 border-t border-border-subtle p-4">
|
|
128
|
+
{footer}
|
|
129
|
+
</footer>
|
|
130
|
+
) : null}
|
|
131
|
+
</Modal>
|
|
132
|
+
)
|
|
133
|
+
}
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -180,7 +180,7 @@ test("a slot forwards what an outer slot gave it", async () => {
|
|
|
180
180
|
* ref and the attribute to have all three arrived.
|
|
181
181
|
*/
|
|
182
182
|
test("a slot forwards what an outer slot gave it, in the other order", async () => {
|
|
183
|
-
const { canvas } = await mountStory(Nested)
|
|
183
|
+
const { body, canvas } = await mountStory(Nested)
|
|
184
184
|
|
|
185
185
|
const control = expectAgentDrivable(canvas, {
|
|
186
186
|
name: "Archive path",
|
|
@@ -192,8 +192,10 @@ test("a slot forwards what an outer slot gave it, in the other order", async ()
|
|
|
192
192
|
// only have been wired to the `<input>`.
|
|
193
193
|
control.focus()
|
|
194
194
|
|
|
195
|
+
// The tip portals to the body now; the `aria-describedby` link still
|
|
196
|
+
// resolves across the boundary, asserted below.
|
|
195
197
|
await waitFor(() => {
|
|
196
|
-
expect(
|
|
198
|
+
expect(body.getByRole("tooltip")).toHaveTextContent(
|
|
197
199
|
"Where finished rips are moved.",
|
|
198
200
|
)
|
|
199
201
|
})
|
|
@@ -224,7 +226,7 @@ test("a slot forwards what an outer slot gave it, in the other order", async ()
|
|
|
224
226
|
* tip.
|
|
225
227
|
*/
|
|
226
228
|
test("a tip is described alongside the field's own text, not instead of it", async () => {
|
|
227
|
-
const { canvas } = await mountStory(Nested)
|
|
229
|
+
const { body, canvas } = await mountStory(Nested)
|
|
228
230
|
|
|
229
231
|
const control = expectAgentDrivable(canvas, {
|
|
230
232
|
name: "Rename pattern",
|
|
@@ -237,7 +239,7 @@ test("a tip is described alongside the field's own text, not instead of it", asy
|
|
|
237
239
|
// tip is missing, and the reason this is testable without a
|
|
238
240
|
// pointer at all.
|
|
239
241
|
await waitFor(() => {
|
|
240
|
-
expect(
|
|
242
|
+
expect(body.getByRole("tooltip")).toHaveTextContent(
|
|
241
243
|
"A JavaScript regular expression.",
|
|
242
244
|
)
|
|
243
245
|
})
|
|
@@ -10,10 +10,11 @@ A thumbnail that opens its own full-size view. Three apps hand-roll this — a p
|
|
|
10
10
|
a still, a scan the operator wants to read — and none get the focus trap or the scroll
|
|
11
11
|
lock right, because the interesting part is not the overlay, it is the dialog underneath.
|
|
12
12
|
|
|
13
|
-
So a `Lightbox` is a **skin over `
|
|
14
|
-
`
|
|
15
|
-
this adds the image-specific part — a `cursor-zoom-in` trigger and an
|
|
16
|
-
image clamped to the viewport, so a 2:3 poster and a 16:9 still both land
|
|
13
|
+
So a `Lightbox` is a **skin over `Dialog`**, not a second dialog. `Dialog` (through
|
|
14
|
+
`Modal`/`OverlayPanel`) owns the portal, the scrim, Escape, the focus trap and
|
|
15
|
+
`lockScrollBehind`; this adds the image-specific part — a `cursor-zoom-in` trigger and an
|
|
16
|
+
`object-contain` image clamped to the viewport, so a 2:3 poster and a 16:9 still both land
|
|
17
|
+
whole.
|
|
17
18
|
|
|
18
19
|
<Canvas of={LightboxStories.Default} />
|
|
19
20
|
|
|
@@ -54,9 +55,9 @@ unread; the caller is the one truth.
|
|
|
54
55
|
## What it inherits, and does not re-test
|
|
55
56
|
|
|
56
57
|
Escape, the focus trap, focus restore to the trigger, the scrim and the scroll lock are
|
|
57
|
-
all `
|
|
58
|
-
that the thumbnail opens the enlarged image, that the caption rides along, and
|
|
59
|
-
contract `
|
|
60
|
-
and focus back to the thumbnail.
|
|
58
|
+
all `Dialog`'s (via `Modal`), asserted in their own tests. The lightbox's tests assert only
|
|
59
|
+
the skin: that the thumbnail opens the enlarged image, that the caption rides along, and
|
|
60
|
+
that the contract `Dialog` guarantees is not quietly dropped by the wrapper — Escape →
|
|
61
|
+
`onClose`, and focus back to the thumbnail.
|
|
61
62
|
|
|
62
63
|
<Canvas of={LightboxStories.Interactive} />
|
|
@@ -66,8 +66,8 @@ export const WithCaption: Story = {
|
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Boards here are a trigger plus one open overlay, not a grid — a
|
|
69
|
-
* lightbox is a `
|
|
70
|
-
* side by side is not
|
|
69
|
+
* lightbox is a `Dialog` underneath, and a dialog covers the viewport,
|
|
70
|
+
* so two side by side is not the real arrangement.
|
|
71
71
|
*/
|
|
72
72
|
export const AllStates: Story = {
|
|
73
73
|
render: () => (
|
|
@@ -106,8 +106,8 @@ const ControlledDemo = (): ReactNode => {
|
|
|
106
106
|
alt="THE OUTFIT poster"
|
|
107
107
|
caption="Opened from a button, not the image"
|
|
108
108
|
isOpen={isVisible}
|
|
109
|
-
onOpenChange={(
|
|
110
|
-
if (!
|
|
109
|
+
onOpenChange={(isNextOpen) => {
|
|
110
|
+
if (!isNextOpen) {
|
|
111
111
|
hide()
|
|
112
112
|
}
|
|
113
113
|
}}
|
|
@@ -122,7 +122,7 @@ export const Controlled: Story = {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
* The keyboard contract, inherited from `
|
|
125
|
+
* The keyboard contract, inherited from `Dialog` and asserted here so
|
|
126
126
|
* the skin cannot quietly drop it: the trigger opens, Escape routes
|
|
127
127
|
* through `onClose`, and focus returns to the thumbnail button.
|
|
128
128
|
*/
|