@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
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} />
|