@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/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
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useLatestRef,
|
|
3
|
+
useUniqueId,
|
|
4
|
+
} from "@charcuterie/logic"
|
|
5
|
+
import {
|
|
6
|
+
FloatingFocusManager,
|
|
7
|
+
FloatingPortal,
|
|
8
|
+
useDismiss,
|
|
9
|
+
useFloating,
|
|
10
|
+
useInteractions,
|
|
11
|
+
useRole,
|
|
12
|
+
} from "@floating-ui/react"
|
|
13
|
+
import type { ReactNode } from "react"
|
|
14
|
+
import { useCallback, useEffect } from "react"
|
|
15
|
+
|
|
16
|
+
import { lockScrollBehind } from "./lockScrollBehind.ts"
|
|
17
|
+
import { useOverlayStack } from "./OverlayStack.tsx"
|
|
18
|
+
import { SharedBackdrop } from "./SharedBackdrop.tsx"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The portalled, backdrop-backed, focus-trapping panel that `Modal`
|
|
22
|
+
* renders. No chrome — that is `Dialog`'s job — only the four things
|
|
23
|
+
* every backdrop overlay needs: it portals to `document.body`, a
|
|
24
|
+
* scrim stands behind it, focus is trapped inside it, and Escape or an
|
|
25
|
+
* outside press closes it.
|
|
26
|
+
*
|
|
27
|
+
* ### Portalled, and the top layer is gone on purpose
|
|
28
|
+
*
|
|
29
|
+
* This supersedes the `<dialog>.showModal()` top-layer approach. The
|
|
30
|
+
* top layer paints above everything but cannot escape an
|
|
31
|
+
* `overflow: hidden` ancestor's *clipping* in every case the fleet
|
|
32
|
+
* hit; a portal to `document.body` does. The objection the old
|
|
33
|
+
* decision raised — a portal moves the node out of a scoped query —
|
|
34
|
+
* is answered by `useRole` writing `aria-controls`/`aria-labelledby`
|
|
35
|
+
* so the trigger→panel link is followable across the boundary, and by
|
|
36
|
+
* the stories scoping panels to `within(document.body)` while the
|
|
37
|
+
* structural `expectAgentDrivable` needs no change.
|
|
38
|
+
*
|
|
39
|
+
* ### Only the top of the stack is live
|
|
40
|
+
*
|
|
41
|
+
* `useDismiss` (Escape + outside press) is enabled **only when this is
|
|
42
|
+
* the top** of the stack, so an outside press or Escape closes the
|
|
43
|
+
* innermost modal and nothing under it — the same rule the platform's
|
|
44
|
+
* nested `<dialog>` gave for free, without a hand-rolled document
|
|
45
|
+
* keydown racing the focus manager. Everything below the top is
|
|
46
|
+
* `inert`, which is what `expectHiddenFromAgents` asserts and what
|
|
47
|
+
* makes `FloatingFocusManager`'s trap unambiguous.
|
|
48
|
+
*
|
|
49
|
+
* ### One accessible name, required
|
|
50
|
+
*
|
|
51
|
+
* A `role="dialog"` takes no name from its content, so one of
|
|
52
|
+
* `aria-label` / `aria-labelledby` must be present. Missing both is a
|
|
53
|
+
* dev-time warning, not a throw — the lesson `Spinner`, `Popover`, and
|
|
54
|
+
* `Modal` each learned, applied without breaking a render.
|
|
55
|
+
*/
|
|
56
|
+
export type OverlayPanelProps = {
|
|
57
|
+
"aria-label"?: string
|
|
58
|
+
"aria-labelledby"?: string
|
|
59
|
+
children: ReactNode
|
|
60
|
+
className?: string
|
|
61
|
+
/** Escape and an outside press both close. */
|
|
62
|
+
isDismissable?: boolean
|
|
63
|
+
isVisible: boolean
|
|
64
|
+
onClose: () => void
|
|
65
|
+
role?: "alertdialog" | "dialog"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const OverlayPanel = ({
|
|
69
|
+
"aria-label": ariaLabel,
|
|
70
|
+
"aria-labelledby": ariaLabelledBy,
|
|
71
|
+
children,
|
|
72
|
+
className,
|
|
73
|
+
isDismissable = true,
|
|
74
|
+
isVisible,
|
|
75
|
+
onClose,
|
|
76
|
+
role = "dialog",
|
|
77
|
+
}: OverlayPanelProps): ReactNode => {
|
|
78
|
+
const stack = useOverlayStack()
|
|
79
|
+
|
|
80
|
+
const id = useUniqueId()
|
|
81
|
+
|
|
82
|
+
// A stable close, so registering it does not churn the stack as the
|
|
83
|
+
// caller's `onClose` closure changes identity between renders.
|
|
84
|
+
const onCloseRef = useLatestRef(onClose)
|
|
85
|
+
|
|
86
|
+
const stableOnClose = useCallback(() => {
|
|
87
|
+
onCloseRef.current()
|
|
88
|
+
}, [onCloseRef])
|
|
89
|
+
|
|
90
|
+
const isTop = !stack.isProvided || stack.topId === id
|
|
91
|
+
|
|
92
|
+
const { context, refs } = useFloating({
|
|
93
|
+
open: isVisible,
|
|
94
|
+
onOpenChange: (isNextVisible) => {
|
|
95
|
+
if (!isNextVisible) {
|
|
96
|
+
onClose()
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const { getFloatingProps } = useInteractions([
|
|
102
|
+
useDismiss(context, {
|
|
103
|
+
escapeKey: isDismissable && isTop,
|
|
104
|
+
// The top's outside press is the backdrop click. A per-panel
|
|
105
|
+
// `outsidePress` on a *lower* modal would close it too, so it is
|
|
106
|
+
// scoped to the top — which is also why the scrim needs no click
|
|
107
|
+
// handler of its own.
|
|
108
|
+
outsidePress: isDismissable && isTop,
|
|
109
|
+
}),
|
|
110
|
+
useRole(context, { role }),
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
const { register, unregister } = stack
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!isVisible) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
register({ id, onClose: stableOnClose })
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
unregister(id)
|
|
124
|
+
}
|
|
125
|
+
}, [id, isVisible, register, stableOnClose, unregister])
|
|
126
|
+
|
|
127
|
+
// Ref-counted through `lockScrollBehind`'s module scope, so N
|
|
128
|
+
// stacked panels lock the page once and free it only when the last
|
|
129
|
+
// closes — with or without a provider.
|
|
130
|
+
useEffect(
|
|
131
|
+
() => (isVisible ? lockScrollBehind() : undefined),
|
|
132
|
+
[isVisible],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (
|
|
137
|
+
isVisible &&
|
|
138
|
+
ariaLabel === undefined &&
|
|
139
|
+
ariaLabelledBy === undefined
|
|
140
|
+
) {
|
|
141
|
+
console.warn(
|
|
142
|
+
'OverlayPanel: a modal needs an accessible name. Pass `aria-label` or `aria-labelledby` — a `role="dialog"` takes none from its content and is announced as just "dialog".',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}, [ariaLabel, ariaLabelledBy, isVisible])
|
|
146
|
+
|
|
147
|
+
if (!isVisible) {
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<>
|
|
153
|
+
{/* A self-hosting `Modal` renders its own scrim; under a
|
|
154
|
+
provider the shared one already stands behind every panel. */}
|
|
155
|
+
{stack.isProvided ? null : (
|
|
156
|
+
<SharedBackdrop isVisible />
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<FloatingPortal>
|
|
160
|
+
<div className="fixed inset-0 z-[var(--layer-modal)] flex items-center justify-center">
|
|
161
|
+
<FloatingFocusManager
|
|
162
|
+
context={context}
|
|
163
|
+
// Only the top traps focus; a lower panel is `inert`
|
|
164
|
+
// anyway, and two live traps fight over one caret.
|
|
165
|
+
disabled={!isTop}
|
|
166
|
+
modal
|
|
167
|
+
>
|
|
168
|
+
<div
|
|
169
|
+
{...getFloatingProps()}
|
|
170
|
+
// Spread rather than written as literal attributes:
|
|
171
|
+
// both `dialog` and `alertdialog` take a name, but the
|
|
172
|
+
// role arrives as a variable and a static a11y linter
|
|
173
|
+
// cannot confirm that through it. `useRole` supplies the
|
|
174
|
+
// role itself; these are the accessible name.
|
|
175
|
+
{...{
|
|
176
|
+
"aria-label": ariaLabel,
|
|
177
|
+
"aria-labelledby": ariaLabelledBy,
|
|
178
|
+
}}
|
|
179
|
+
className={className}
|
|
180
|
+
// The fallback the plan calls for: `inert` on every panel
|
|
181
|
+
// but the top, so a lower modal is unreachable across the
|
|
182
|
+
// portal boundary even where `FloatingFocusManager`'s
|
|
183
|
+
// outside-hiding does not reach a sibling portal.
|
|
184
|
+
inert={isTop ? undefined : true}
|
|
185
|
+
ref={refs.setFloating}
|
|
186
|
+
role={role}
|
|
187
|
+
>
|
|
188
|
+
{children}
|
|
189
|
+
</div>
|
|
190
|
+
</FloatingFocusManager>
|
|
191
|
+
</div>
|
|
192
|
+
</FloatingPortal>
|
|
193
|
+
</>
|
|
194
|
+
)
|
|
195
|
+
}
|