@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.
- 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 +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 +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 +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
package/src/Menu/Menu.test.tsx
CHANGED
|
@@ -15,8 +15,7 @@ const {
|
|
|
15
15
|
} = composeStories(stories)
|
|
16
16
|
|
|
17
17
|
test("the menu is named, and its items are menuitems", async () => {
|
|
18
|
-
const {
|
|
19
|
-
await mountStory(AllStates)
|
|
18
|
+
const { body } = await mountStory(AllStates)
|
|
20
19
|
|
|
21
20
|
// **Named by its trigger**, not by a `label` prop. `useRole` puts
|
|
22
21
|
// `aria-labelledby` on the panel pointing at the button, and
|
|
@@ -27,17 +26,17 @@ test("the menu is named, and its items are menuitems", async () => {
|
|
|
27
26
|
// overlay with **no trigger relationship** (`Spinner`'s
|
|
28
27
|
// `role="status"`, `Popover`'s `role="dialog"`) needs its own
|
|
29
28
|
// name. A menu has one.
|
|
30
|
-
expectAgentDrivable(
|
|
29
|
+
const menu = expectAgentDrivable(body, {
|
|
31
30
|
name: "Bay 3",
|
|
32
31
|
role: "menu",
|
|
33
32
|
})
|
|
34
33
|
|
|
35
|
-
expectAgentDrivable(
|
|
34
|
+
expectAgentDrivable(body, {
|
|
36
35
|
name: "Retry title",
|
|
37
36
|
role: "menuitem",
|
|
38
37
|
})
|
|
39
38
|
|
|
40
|
-
await expectNoAxeViolations(
|
|
39
|
+
await expectNoAxeViolations(menu)
|
|
41
40
|
})
|
|
42
41
|
|
|
43
42
|
/**
|
|
@@ -46,7 +45,7 @@ test("the menu is named, and its items are menuitems", async () => {
|
|
|
46
45
|
* still on the trigger, with a menu they cannot reach.
|
|
47
46
|
*/
|
|
48
47
|
test("opening moves focus into the menu", async () => {
|
|
49
|
-
const { canvas } = await mountStory(Interactive)
|
|
48
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
50
49
|
|
|
51
50
|
await userEvent.click(
|
|
52
51
|
expectAgentDrivable(canvas, {
|
|
@@ -57,7 +56,7 @@ test("opening moves focus into the menu", async () => {
|
|
|
57
56
|
|
|
58
57
|
await waitFor(() => {
|
|
59
58
|
expect(
|
|
60
|
-
expectAgentDrivable(
|
|
59
|
+
expectAgentDrivable(body, {
|
|
61
60
|
name: "Retry title",
|
|
62
61
|
role: "menuitem",
|
|
63
62
|
}),
|
|
@@ -66,14 +65,14 @@ test("opening moves focus into the menu", async () => {
|
|
|
66
65
|
})
|
|
67
66
|
|
|
68
67
|
test("the arrow keys move within the menu and skip the disabled item", async () => {
|
|
69
|
-
const {
|
|
68
|
+
const { body } = await mountStory(AllStates)
|
|
70
69
|
|
|
71
|
-
const skip = expectAgentDrivable(
|
|
70
|
+
const skip = expectAgentDrivable(body, {
|
|
72
71
|
name: "Skip title",
|
|
73
72
|
role: "menuitem",
|
|
74
73
|
})
|
|
75
74
|
|
|
76
|
-
const eject = expectAgentDrivable(
|
|
75
|
+
const eject = expectAgentDrivable(body, {
|
|
77
76
|
name: "Eject disc",
|
|
78
77
|
role: "menuitem",
|
|
79
78
|
})
|
|
@@ -84,7 +83,7 @@ test("the arrow keys move within the menu and skip the disabled item", async ()
|
|
|
84
83
|
// clicking an item to "focus" it closes the thing under test. This
|
|
85
84
|
// story opens already visible, and opening is what puts focus on
|
|
86
85
|
// the first item.
|
|
87
|
-
const retry = expectAgentDrivable(
|
|
86
|
+
const retry = expectAgentDrivable(body, {
|
|
88
87
|
name: "Retry title",
|
|
89
88
|
role: "menuitem",
|
|
90
89
|
})
|
|
@@ -118,9 +117,9 @@ test("the arrow keys move within the menu and skip the disabled item", async ()
|
|
|
118
117
|
* that is the pattern, not a bug.
|
|
119
118
|
*/
|
|
120
119
|
test("the menu has one tab stop", async () => {
|
|
121
|
-
const {
|
|
120
|
+
const { body } = await mountStory(AllStates)
|
|
122
121
|
|
|
123
|
-
const menu = expectAgentDrivable(
|
|
122
|
+
const menu = expectAgentDrivable(body, {
|
|
124
123
|
name: "Bay 3",
|
|
125
124
|
role: "menu",
|
|
126
125
|
})
|
|
@@ -135,7 +134,7 @@ test("the menu has one tab stop", async () => {
|
|
|
135
134
|
})
|
|
136
135
|
|
|
137
136
|
test("choosing an item dismisses the menu", async () => {
|
|
138
|
-
const { canvas } = await mountStory(Interactive)
|
|
137
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
139
138
|
|
|
140
139
|
await userEvent.click(
|
|
141
140
|
expectAgentDrivable(canvas, {
|
|
@@ -145,7 +144,7 @@ test("choosing an item dismisses the menu", async () => {
|
|
|
145
144
|
)
|
|
146
145
|
|
|
147
146
|
await userEvent.click(
|
|
148
|
-
expectAgentDrivable(
|
|
147
|
+
expectAgentDrivable(body, {
|
|
149
148
|
name: "Retry title",
|
|
150
149
|
role: "menuitem",
|
|
151
150
|
}),
|
|
@@ -154,14 +153,12 @@ test("choosing an item dismisses the menu", async () => {
|
|
|
154
153
|
// A menu that stays open after an action has fired is the one
|
|
155
154
|
// interaction users read as "it didn't work".
|
|
156
155
|
await waitFor(() => {
|
|
157
|
-
expect(
|
|
158
|
-
0,
|
|
159
|
-
)
|
|
156
|
+
expect(body.queryAllByRole("menuitem")).toHaveLength(0)
|
|
160
157
|
})
|
|
161
158
|
})
|
|
162
159
|
|
|
163
160
|
test("Escape dismisses without choosing", async () => {
|
|
164
|
-
const { canvas } = await mountStory(Interactive)
|
|
161
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
165
162
|
|
|
166
163
|
await userEvent.click(
|
|
167
164
|
expectAgentDrivable(canvas, {
|
|
@@ -172,37 +169,50 @@ test("Escape dismisses without choosing", async () => {
|
|
|
172
169
|
|
|
173
170
|
await waitFor(() => {
|
|
174
171
|
expect(
|
|
175
|
-
|
|
172
|
+
body.queryAllByRole("menuitem").length,
|
|
176
173
|
).toBeGreaterThan(0)
|
|
177
174
|
})
|
|
178
175
|
|
|
179
176
|
await userEvent.keyboard("{Escape}")
|
|
180
177
|
|
|
181
178
|
await waitFor(() => {
|
|
182
|
-
expect(
|
|
183
|
-
0,
|
|
184
|
-
)
|
|
179
|
+
expect(body.queryAllByRole("menuitem")).toHaveLength(0)
|
|
185
180
|
})
|
|
186
181
|
})
|
|
187
182
|
|
|
188
183
|
/**
|
|
189
|
-
* The panel
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
184
|
+
* The panel portals to `document.body` — escaping any
|
|
185
|
+
* `overflow: hidden` ancestor — and the trigger→panel link survives
|
|
186
|
+
* the boundary: `useRole` writes `aria-labelledby` on the menu
|
|
187
|
+
* pointing back at the button that names it. A scoped query for the
|
|
188
|
+
* panel now reaches for `body`; the trigger stays in the canvas.
|
|
193
189
|
*/
|
|
194
|
-
test("the menu
|
|
195
|
-
const { canvas, canvasElement } =
|
|
190
|
+
test("the menu portals to the body, still named by its trigger", async () => {
|
|
191
|
+
const { body, canvas, canvasElement } =
|
|
196
192
|
await mountStory(AllVariants)
|
|
197
193
|
|
|
198
|
-
const
|
|
194
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
195
|
+
name: "Bay 2",
|
|
196
|
+
role: "button",
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const menu = expectAgentDrivable(body, {
|
|
199
200
|
name: "Bay 2",
|
|
200
201
|
role: "menu",
|
|
201
202
|
})
|
|
202
203
|
|
|
203
|
-
await expect(canvasElement).toContainElement(menu)
|
|
204
|
+
await expect(canvasElement).not.toContainElement(menu)
|
|
205
|
+
|
|
206
|
+
await expect(document.body).toContainElement(menu)
|
|
207
|
+
|
|
208
|
+
// The link, across the portal: the menu is named by pointing at its
|
|
209
|
+
// trigger, and the id really resolves.
|
|
210
|
+
await expect(menu).toHaveAttribute(
|
|
211
|
+
"aria-labelledby",
|
|
212
|
+
trigger.id,
|
|
213
|
+
)
|
|
204
214
|
|
|
205
|
-
await expectNoAxeViolations(
|
|
215
|
+
await expectNoAxeViolations(menu)
|
|
206
216
|
})
|
|
207
217
|
|
|
208
218
|
/**
|
|
@@ -239,8 +249,7 @@ const expectAnchoredTrigger = (trigger: HTMLElement) => {
|
|
|
239
249
|
}
|
|
240
250
|
|
|
241
251
|
test("a menu sharing its trigger with a tooltip is still anchored to it", async () => {
|
|
242
|
-
const {
|
|
243
|
-
await mountStory(SharedTrigger)
|
|
252
|
+
const { body, canvas } = await mountStory(SharedTrigger)
|
|
244
253
|
|
|
245
254
|
const trigger = expectAgentDrivable(canvas, {
|
|
246
255
|
name: "Bay 5",
|
|
@@ -252,7 +261,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
|
|
|
252
261
|
await userEvent.click(trigger)
|
|
253
262
|
|
|
254
263
|
const menu = await waitFor(() =>
|
|
255
|
-
expectAgentDrivable(
|
|
264
|
+
expectAgentDrivable(body, { role: "menu" }),
|
|
256
265
|
)
|
|
257
266
|
|
|
258
267
|
// `bottom-start` with `offset(4)`: flush with the trigger's
|
|
@@ -273,7 +282,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
|
|
|
273
282
|
)
|
|
274
283
|
})
|
|
275
284
|
|
|
276
|
-
await expectNoAxeViolations(
|
|
285
|
+
await expectNoAxeViolations(menu)
|
|
277
286
|
})
|
|
278
287
|
|
|
279
288
|
/**
|
|
@@ -282,7 +291,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
|
|
|
282
291
|
* pointing at nothing and the test above would still pass.
|
|
283
292
|
*/
|
|
284
293
|
test("the tooltip sharing that trigger is anchored to it too", async () => {
|
|
285
|
-
const { canvas } = await mountStory(SharedTrigger)
|
|
294
|
+
const { body, canvas } = await mountStory(SharedTrigger)
|
|
286
295
|
|
|
287
296
|
const trigger = expectAgentDrivable(canvas, {
|
|
288
297
|
name: "Bay 5",
|
|
@@ -294,7 +303,7 @@ test("the tooltip sharing that trigger is anchored to it too", async () => {
|
|
|
294
303
|
trigger.focus()
|
|
295
304
|
|
|
296
305
|
const tip = await waitFor(() =>
|
|
297
|
-
expectAgentDrivable(
|
|
306
|
+
expectAgentDrivable(body, { role: "tooltip" }),
|
|
298
307
|
)
|
|
299
308
|
|
|
300
309
|
// The **main axis** only: `top` with `offset(6)`. The cross axis
|
package/src/Menu/Menu.tsx
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
selectTabIndex,
|
|
3
|
-
useClonedChild,
|
|
4
3
|
useRovingFocus,
|
|
5
4
|
} from "@charcuterie/logic"
|
|
6
5
|
import type { Placement } from "@floating-ui/react"
|
|
7
6
|
import {
|
|
8
|
-
autoUpdate,
|
|
9
7
|
FloatingFocusManager,
|
|
10
|
-
|
|
11
|
-
offset,
|
|
12
|
-
shift,
|
|
13
|
-
useDismiss,
|
|
14
|
-
useFloating,
|
|
15
|
-
useInteractions,
|
|
16
|
-
useRole,
|
|
8
|
+
FloatingPortal,
|
|
17
9
|
} from "@floating-ui/react"
|
|
18
10
|
import type { ReactElement, ReactNode } from "react"
|
|
19
11
|
import { useEffect, useRef } from "react"
|
|
20
12
|
|
|
13
|
+
import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
|
|
14
|
+
import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
|
|
21
15
|
import { toClassName } from "../toClassName.ts"
|
|
22
16
|
import { MenuAction } from "./MenuAction.tsx"
|
|
23
17
|
|
|
@@ -112,8 +106,6 @@ export const Menu = ({
|
|
|
112
106
|
new Map<string, HTMLButtonElement>(),
|
|
113
107
|
)
|
|
114
108
|
|
|
115
|
-
const panelElement = useRef<HTMLDivElement>(null)
|
|
116
|
-
|
|
117
109
|
const focus = useRovingFocus<string>()
|
|
118
110
|
|
|
119
111
|
const { activeValue, registeredValues, setActiveValue } =
|
|
@@ -121,53 +113,21 @@ export const Menu = ({
|
|
|
121
113
|
|
|
122
114
|
const [firstValue] = registeredValues
|
|
123
115
|
|
|
124
|
-
const {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
116
|
+
const {
|
|
117
|
+
clonedTrigger,
|
|
118
|
+
context,
|
|
119
|
+
floatingStyles,
|
|
120
|
+
getFloatingProps,
|
|
121
|
+
setFloating,
|
|
122
|
+
} = useAnchoredOverlay({
|
|
123
|
+
isVisible,
|
|
124
|
+
offsetValue: 4,
|
|
125
|
+
onDismiss,
|
|
131
126
|
placement,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
whileElementsMounted: autoUpdate,
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
const { getFloatingProps, getReferenceProps } =
|
|
138
|
-
useInteractions([
|
|
139
|
-
useDismiss(context),
|
|
140
|
-
useRole(context, { role: "menu" }),
|
|
141
|
-
])
|
|
142
|
-
|
|
143
|
-
const clonedTrigger = useClonedChild(trigger, {
|
|
144
|
-
...getReferenceProps(),
|
|
145
|
-
ref: refs.setReference,
|
|
127
|
+
role: "menu",
|
|
128
|
+
trigger,
|
|
146
129
|
})
|
|
147
130
|
|
|
148
|
-
/**
|
|
149
|
-
* The other half of the `isConnected` guard below. If the ref
|
|
150
|
-
* fired while the node was still detached, nothing has shown the
|
|
151
|
-
* popover — and a `[popover]` that was never shown is
|
|
152
|
-
* `display: none`, so the menu renders, passes typecheck, and is
|
|
153
|
-
* invisible to `getByRole` and to a user alike.
|
|
154
|
-
*
|
|
155
|
-
* Effects run after the tree is connected, so this is the
|
|
156
|
-
* backstop. It is idempotent: `:popover-open` is the same question
|
|
157
|
-
* the ref asks.
|
|
158
|
-
*/
|
|
159
|
-
useEffect(() => {
|
|
160
|
-
const panel = panelElement.current
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
isVisible &&
|
|
164
|
-
panel?.isConnected &&
|
|
165
|
-
!panel.matches(":popover-open")
|
|
166
|
-
) {
|
|
167
|
-
panel.showPopover()
|
|
168
|
-
}
|
|
169
|
-
}, [isVisible])
|
|
170
|
-
|
|
171
131
|
/**
|
|
172
132
|
* Opening **makes** a member active, and this is the step the
|
|
173
133
|
* first version was missing.
|
|
@@ -205,95 +165,74 @@ export const Menu = ({
|
|
|
205
165
|
{clonedTrigger}
|
|
206
166
|
|
|
207
167
|
{isVisible ? (
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<div
|
|
220
|
-
{...getFloatingProps()}
|
|
221
|
-
className={toClassName(
|
|
222
|
-
"inset-auto m-0 flex min-w-48 flex-col gap-0.5 rounded-md border border-border-default bg-surface-overlay p-1 shadow-medium",
|
|
223
|
-
className,
|
|
224
|
-
)}
|
|
225
|
-
onKeyDown={(keyEvent) => {
|
|
226
|
-
const commands: Record<string, () => void> = {
|
|
227
|
-
ArrowDown: focus.next,
|
|
228
|
-
ArrowUp: focus.previous,
|
|
229
|
-
End: focus.last,
|
|
230
|
-
Home: focus.first,
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const command = commands[keyEvent.key]
|
|
234
|
-
|
|
235
|
-
if (command) {
|
|
236
|
-
// Arrow keys scroll the page by default, and a menu
|
|
237
|
-
// that scrolls the document behind it while moving
|
|
238
|
-
// focus is the hand-rolled version of this
|
|
239
|
-
// component.
|
|
240
|
-
keyEvent.preventDefault()
|
|
241
|
-
|
|
242
|
-
command()
|
|
243
|
-
}
|
|
244
|
-
}}
|
|
245
|
-
popover="manual"
|
|
246
|
-
ref={(node) => {
|
|
247
|
-
refs.setFloating(node)
|
|
248
|
-
|
|
249
|
-
panelElement.current = node
|
|
250
|
-
|
|
251
|
-
// A ref callback rather than an effect, for the same
|
|
252
|
-
// reason as `Popover`: a `[popover]` is
|
|
253
|
-
// `display: none` until shown, and every child effect
|
|
254
|
-
// — including the focus move above — would otherwise
|
|
255
|
-
// be aiming at a hidden element.
|
|
256
|
-
//
|
|
257
|
-
// `isConnected` is the guard `Popover` does not need
|
|
258
|
-
// and this does. `FloatingFocusManager` renders a pair
|
|
259
|
-
// of focus guards around its child, so React commits
|
|
260
|
-
// this subtree in an order where the ref can fire
|
|
261
|
-
// while the node is still detached — and
|
|
262
|
-
// `showPopover()` on a disconnected element throws
|
|
263
|
-
// `InvalidStateError`, which surfaces as an unhandled
|
|
264
|
-
// rejection rather than as a failing assertion.
|
|
265
|
-
if (
|
|
266
|
-
node?.isConnected &&
|
|
267
|
-
!node.matches(":popover-open")
|
|
268
|
-
) {
|
|
269
|
-
node.showPopover()
|
|
270
|
-
}
|
|
271
|
-
}}
|
|
272
|
-
// Duplicated from `getFloatingProps()` on purpose, and
|
|
273
|
-
// the two cannot differ — `useRole(context, { role:
|
|
274
|
-
// "menu" })` above is where it comes from. Stated here
|
|
275
|
-
// because a linter cannot see a role through a spread.
|
|
276
|
-
role="menu"
|
|
277
|
-
style={floatingStyles}
|
|
168
|
+
<FloatingPortal>
|
|
169
|
+
<FloatingFocusManager
|
|
170
|
+
context={context}
|
|
171
|
+
// `false`, so Tab leaves the menu and lands on the next
|
|
172
|
+
// control in the page — which is what closes it, through
|
|
173
|
+
// `useDismiss`. A trapped menu is a dialog.
|
|
174
|
+
modal={false}
|
|
175
|
+
// Focus is moved by the effect above, to whichever item
|
|
176
|
+
// `RovingFocus` made active. Letting the manager also aim
|
|
177
|
+
// it is two owners for one caret.
|
|
178
|
+
initialFocus={-1}
|
|
278
179
|
>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
180
|
+
<div
|
|
181
|
+
{...getFloatingProps()}
|
|
182
|
+
className={toClassName(
|
|
183
|
+
PANEL_SURFACE_CLASS,
|
|
184
|
+
"z-[var(--layer-modal)] flex min-w-48 flex-col gap-0.5 p-1",
|
|
185
|
+
className,
|
|
186
|
+
)}
|
|
187
|
+
onKeyDown={(keyEvent) => {
|
|
188
|
+
const commands: Record<string, () => void> =
|
|
189
|
+
{
|
|
190
|
+
ArrowDown: focus.next,
|
|
191
|
+
ArrowUp: focus.previous,
|
|
192
|
+
End: focus.last,
|
|
193
|
+
Home: focus.first,
|
|
291
194
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
195
|
+
|
|
196
|
+
const command = commands[keyEvent.key]
|
|
197
|
+
|
|
198
|
+
if (command) {
|
|
199
|
+
// Arrow keys scroll the page by default, and a menu
|
|
200
|
+
// that scrolls the document behind it while moving
|
|
201
|
+
// focus is the hand-rolled version of this
|
|
202
|
+
// component.
|
|
203
|
+
keyEvent.preventDefault()
|
|
204
|
+
|
|
205
|
+
command()
|
|
206
|
+
}
|
|
207
|
+
}}
|
|
208
|
+
ref={setFloating}
|
|
209
|
+
// Duplicated from `getFloatingProps()` on purpose, and
|
|
210
|
+
// the two cannot differ — `useRole(context, { role:
|
|
211
|
+
// "menu" })` in the hook is where it comes from. Stated
|
|
212
|
+
// here because a linter cannot see a role through a
|
|
213
|
+
// spread.
|
|
214
|
+
role="menu"
|
|
215
|
+
style={floatingStyles}
|
|
216
|
+
>
|
|
217
|
+
{items.map((item) => (
|
|
218
|
+
<MenuAction
|
|
219
|
+
item={item}
|
|
220
|
+
key={item.key}
|
|
221
|
+
onDismiss={onDismiss}
|
|
222
|
+
register={focus.register}
|
|
223
|
+
tabIndex={selectTabIndex(focus, item.key)}
|
|
224
|
+
trackElement={(key, element) => {
|
|
225
|
+
if (element) {
|
|
226
|
+
itemElements.current.set(key, element)
|
|
227
|
+
} else {
|
|
228
|
+
itemElements.current.delete(key)
|
|
229
|
+
}
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
</FloatingFocusManager>
|
|
235
|
+
</FloatingPortal>
|
|
297
236
|
) : null}
|
|
298
237
|
</>
|
|
299
238
|
)
|
package/src/Modal/Modal.mdx
CHANGED
|
@@ -6,10 +6,11 @@ import * as ModalStories from "./Modal.stories";
|
|
|
6
6
|
|
|
7
7
|
# Modal
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
The **base layer**: a backdrop, a focus trap, Escape, and an outside press — and no
|
|
10
|
+
chrome. A dialog is one kind of modal; so is a confirmation, a media lightbox, and
|
|
11
|
+
anything else that wants a scrim over the page. `Modal` is that foundation, and
|
|
12
|
+
[`Dialog`](?path=/docs/components-dialog--docs) is `Modal` plus the
|
|
13
|
+
heading/Close/footer/size chrome the component shipped with before the M8 rebuild.
|
|
13
14
|
|
|
14
15
|
<Canvas of={ModalStories.Default} />
|
|
15
16
|
|
|
@@ -17,97 +18,43 @@ from the platform, correctly, in every browser the fleet runs on.
|
|
|
17
18
|
|
|
18
19
|
<Controls />
|
|
19
20
|
|
|
20
|
-
##
|
|
21
|
+
## Portalled, not the top layer
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
`Modal` renders an `OverlayPanel`, which portals to `document.body`. The old `Modal` rode
|
|
24
|
+
the platform top layer (`<dialog>.showModal()`); this one supersedes that
|
|
25
|
+
([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)),
|
|
26
|
+
because the top layer cannot escape an `overflow: hidden` ancestor's clipping in every
|
|
27
|
+
case the fleet hit, and a portal does. The objection a portal used to raise — that it
|
|
28
|
+
moves the node out of a scoped query — is answered by `useRole` writing `aria-controls` /
|
|
29
|
+
`aria-labelledby` across the boundary, and by stories scoping panel queries to
|
|
30
|
+
`within(document.body)`.
|
|
27
31
|
|
|
28
32
|
```tsx
|
|
29
33
|
const { hide, isVisible, show } = useVisibility()
|
|
30
34
|
|
|
31
|
-
<Button onClick={show}>
|
|
35
|
+
<Button onClick={show}>Open</Button>
|
|
32
36
|
|
|
33
|
-
<Modal
|
|
34
|
-
…
|
|
37
|
+
<Modal aria-label="Read error on title 4" isVisible={isVisible} onClose={hide}>
|
|
38
|
+
…your own markup…
|
|
35
39
|
</Modal>
|
|
36
40
|
```
|
|
37
41
|
|
|
38
|
-
`onClose` is every route out —
|
|
39
|
-
|
|
42
|
+
`onClose` is every route out — Escape, a backdrop press — so there is one place to wire
|
|
43
|
+
and no way to end up with the element shut and the state still open.
|
|
40
44
|
|
|
41
|
-
##
|
|
45
|
+
## One accessible name, required
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
tidiness:
|
|
47
|
+
A `role="dialog"` takes no name from its content, so pass exactly one of `aria-label` or
|
|
48
|
+
`aria-labelledby`. Missing both is a dev-time warning, not a throw.
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
> scoped its queries to.** `canvas.getByRole("dialog")` finds this modal. It would not find
|
|
49
|
-
> a portalled one, and every story here would have to reach for `screen` and give up its
|
|
50
|
-
> scoping.
|
|
50
|
+
<Canvas of={ModalStories.Alert} />
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
## Stacking is zero-config, until it is not
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
attribute the browser manages.
|
|
60
|
-
|
|
61
|
-
<Canvas of={ModalStories.AllVariants} />
|
|
62
|
-
|
|
63
|
-
## What a story cannot press
|
|
64
|
-
|
|
65
|
-
`play`'s `userEvent` is `@testing-library/user-event`, which dispatches **untrusted**
|
|
66
|
-
events, and a browser runs a default action only for trusted input. So a synthetic Escape
|
|
67
|
-
does not make a native `<dialog>` fire `cancel` — it does nothing at all, and a story
|
|
68
|
-
asserting "Escape did not close the non-dismissable dialog" would pass for the wrong
|
|
69
|
-
reason forever.
|
|
70
|
-
|
|
71
|
-
The stories dispatch the close request where the browser would raise it instead, which
|
|
72
|
-
splits the contract honestly: keystroke → `cancel` is the platform's and is not ours to
|
|
73
|
-
test; everything downstream of `cancel` is ours and is asserted. `AllStates` pairs it with
|
|
74
|
-
a differential — the dialog that refuses the close request is then closed by its own
|
|
75
|
-
button — because "still open" is also true of a dialog nothing can close at all.
|
|
76
|
-
|
|
77
|
-
<Canvas of={ModalStories.AllStates} />
|
|
78
|
-
|
|
79
|
-
## `isDismissable={false}` cancels, it does not ignore
|
|
80
|
-
|
|
81
|
-
Escape is `preventDefault`ed rather than dropped. Letting the browser close a dialog the
|
|
82
|
-
app said was not dismissable would leave `isVisible` true and the element shut — the exact
|
|
83
|
-
desync this component exists to prevent.
|
|
84
|
-
|
|
85
|
-
## Scroll behind it is locked, and the count is why
|
|
86
|
-
|
|
87
|
-
`showModal()` makes the page `inert` and then lets you scroll it anyway. The lock is a
|
|
88
|
-
counted module-level one rather than a hook, because nested dialogs are legal and a naive
|
|
89
|
-
lock/unlock pair has the inner dialog's cleanup unlock the page while the outer is still
|
|
90
|
-
open. The release is idempotent, because StrictMode runs an effect's cleanup twice.
|
|
91
|
-
|
|
92
|
-
## `dvh`, and the scrim
|
|
93
|
-
|
|
94
|
-
Every size clamps at `85dvh`; `full` takes the lot. `dvh` rather than `vh` because on a
|
|
95
|
-
phone the two differ by the address bar, and a dialog whose footer is under it is a dialog
|
|
96
|
-
with no buttons.
|
|
97
|
-
|
|
98
|
-
`::backdrop` reads `--color-scrim`, which is a **variant** token: `legible` dims at 0.84
|
|
99
|
-
and `daylight` at 0.66, because how hard a direction separates a dialog from the page is
|
|
100
|
-
exactly the sort of thing a visual direction decides. ePaper's is `transparent` — a panel
|
|
101
|
-
with no opacity cannot dim, so it says so instead of faking it. The `Responsive` story
|
|
102
|
-
asserts the computed `::backdrop` colour is really there, since a `var()` that failed to
|
|
103
|
-
inherit would just look like a design choice.
|
|
104
|
-
|
|
105
|
-
<Canvas of={ModalStories.Responsive} />
|
|
106
|
-
|
|
107
|
-
## A modal is not a container-query citizen
|
|
108
|
-
|
|
109
|
-
It gets no `ContainerBoard`. Its size is measured against the viewport by definition — it
|
|
110
|
-
is the thing covering the viewport — so boarding it at three container widths would be
|
|
111
|
-
measuring something with no bearing on it.
|
|
54
|
+
A lone `Modal` renders its own scrim with no setup. Only when modals **stack** — one
|
|
55
|
+
opened from inside another — does the app need an `OverlayStackProvider` at its root, so
|
|
56
|
+
the N of them share **one** scrim, keep the top live, mark the rest `inert`, and dismiss
|
|
57
|
+
top-first. The scroll lock is ref-counted through the stack, so the page frees only when
|
|
58
|
+
the last modal closes.
|
|
112
59
|
|
|
113
60
|
<Canvas of={ModalStories.Interactive} />
|