@charcuterie/ui 1.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
- package/dist/Combobox/Combobox.d.ts +54 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -0
- package/dist/Combobox/Combobox.js +216 -0
- package/dist/Combobox/Combobox.js.map +1 -0
- package/dist/Combobox/ComboboxOption.d.ts +36 -0
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
- package/dist/Combobox/ComboboxOption.js +32 -0
- package/dist/Combobox/ComboboxOption.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +47 -0
- package/dist/Dialog/Dialog.d.ts.map +1 -0
- package/dist/Dialog/Dialog.js +52 -0
- package/dist/Dialog/Dialog.js.map +1 -0
- package/dist/Lightbox/Lightbox.d.ts +57 -0
- package/dist/Lightbox/Lightbox.d.ts.map +1 -0
- package/dist/Lightbox/Lightbox.js +50 -0
- package/dist/Lightbox/Lightbox.js.map +1 -0
- package/dist/Listbox/Listbox.d.ts +56 -0
- package/dist/Listbox/Listbox.d.ts.map +1 -0
- package/dist/Listbox/Listbox.js +151 -0
- package/dist/Listbox/Listbox.js.map +1 -0
- package/dist/Listbox/ListboxOption.d.ts +36 -0
- package/dist/Listbox/ListboxOption.d.ts.map +1 -0
- package/dist/Listbox/ListboxOption.js +42 -0
- package/dist/Listbox/ListboxOption.js.map +1 -0
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +47 -97
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Modal/Modal.d.ts +11 -59
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +28 -101
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +50 -0
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
- package/dist/Overlay/OverlayPanel.js +72 -0
- package/dist/Overlay/OverlayPanel.js.map +1 -0
- package/dist/Overlay/OverlayStack.d.ts +47 -0
- package/dist/Overlay/OverlayStack.d.ts.map +1 -0
- package/dist/Overlay/OverlayStack.js +46 -0
- package/dist/Overlay/OverlayStack.js.map +1 -0
- package/dist/Overlay/SharedBackdrop.d.ts +24 -0
- package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
- package/dist/Overlay/SharedBackdrop.js +28 -0
- package/dist/Overlay/SharedBackdrop.js.map +1 -0
- package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
- package/dist/Overlay/lockScrollBehind.js.map +1 -0
- package/dist/Overlay/overlayPanelClass.d.ts +17 -0
- package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
- package/dist/Overlay/overlayPanelClass.js +17 -0
- package/dist/Overlay/overlayPanelClass.js.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.js +81 -0
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
- package/dist/Popover/Popover.d.ts +16 -24
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +32 -68
- package/dist/Popover/Popover.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +25 -25
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +1 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +21 -1
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +5 -4
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
- package/src/Combobox/Combobox.mdx +71 -0
- package/src/Combobox/Combobox.stories.tsx +245 -0
- package/src/Combobox/Combobox.test.tsx +289 -0
- package/src/Combobox/Combobox.tsx +513 -0
- package/src/Combobox/ComboboxOption.tsx +88 -0
- package/src/Dialog/Dialog.mdx +87 -0
- package/src/Dialog/Dialog.stories.tsx +347 -0
- package/src/Dialog/Dialog.test.tsx +312 -0
- package/src/Dialog/Dialog.tsx +127 -0
- package/src/Field/Field.test.tsx +6 -4
- package/src/Lightbox/Lightbox.mdx +63 -0
- package/src/Lightbox/Lightbox.stories.tsx +133 -0
- package/src/Lightbox/Lightbox.test.tsx +124 -0
- package/src/Lightbox/Lightbox.tsx +143 -0
- package/src/Listbox/Listbox.mdx +68 -0
- package/src/Listbox/Listbox.stories.tsx +205 -0
- package/src/Listbox/Listbox.test.tsx +198 -0
- package/src/Listbox/Listbox.tsx +295 -0
- package/src/Listbox/ListboxOption.tsx +104 -0
- package/src/Menu/Menu.mdx +11 -13
- package/src/Menu/Menu.test.tsx +47 -38
- package/src/Menu/Menu.tsx +81 -142
- package/src/Modal/Modal.mdx +29 -82
- package/src/Modal/Modal.stories.tsx +55 -188
- package/src/Modal/Modal.test.tsx +44 -177
- package/src/Modal/Modal.tsx +54 -204
- package/src/Overlay/OverlayPanel.tsx +195 -0
- package/src/Overlay/OverlayStack.test.tsx +235 -0
- package/src/Overlay/OverlayStack.tsx +132 -0
- package/src/Overlay/SharedBackdrop.tsx +40 -0
- package/src/Overlay/overlayPanelClass.ts +17 -0
- package/src/Overlay/useAnchoredOverlay.ts +197 -0
- package/src/Popover/Popover.mdx +17 -20
- package/src/Popover/Popover.stories.tsx +33 -0
- package/src/Popover/Popover.test.tsx +68 -32
- package/src/Popover/Popover.tsx +56 -95
- package/src/Tooltip/Tooltip.mdx +12 -9
- package/src/Tooltip/Tooltip.test.tsx +17 -19
- package/src/Tooltip/Tooltip.tsx +35 -32
- package/src/icons.storyHelpers.tsx +22 -0
- package/src/index.ts +22 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +26 -1
- package/src/storyControls.test.ts +25 -12
- package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
- package/dist/Modal/lockScrollBehind.js.map +0 -1
- /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
- /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
- /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { act } from "react"
|
|
3
|
+
import { createRoot } from "react-dom/client"
|
|
4
|
+
import {
|
|
5
|
+
afterAll,
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeAll,
|
|
8
|
+
expect,
|
|
9
|
+
test,
|
|
10
|
+
} from "vitest"
|
|
11
|
+
|
|
12
|
+
import { OverlayPanel } from "./OverlayPanel.tsx"
|
|
13
|
+
import type { OverlayStackValue } from "./OverlayStack.tsx"
|
|
14
|
+
import {
|
|
15
|
+
OverlayStackProvider,
|
|
16
|
+
useOverlayStack,
|
|
17
|
+
} from "./OverlayStack.tsx"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The stack's bookkeeping, driven directly rather than through a
|
|
21
|
+
* component's chrome — this is infrastructure with no story, so it
|
|
22
|
+
* mounts its own root and drives it with `act`, which flushes the
|
|
23
|
+
* passive effects the scroll lock and `requestCloseTop`'s live-top ref
|
|
24
|
+
* both ride on. The act environment is turned on for this file only;
|
|
25
|
+
* ui-dom isolates test files, so it never reaches the story-driven
|
|
26
|
+
* suites next door.
|
|
27
|
+
*/
|
|
28
|
+
// Typed as a string-keyed record so the React flag's own
|
|
29
|
+
// SCREAMING_CASE name never has to be *declared* as a property (the
|
|
30
|
+
// boolean-naming rule wants `is`/`has`); it is only assigned.
|
|
31
|
+
const reactEnvironment = globalThis as unknown as Record<
|
|
32
|
+
string,
|
|
33
|
+
boolean
|
|
34
|
+
>
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
reactEnvironment.IS_REACT_ACT_ENVIRONMENT = true
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
reactEnvironment.IS_REACT_ACT_ENVIRONMENT = false
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const roots: {
|
|
45
|
+
container: HTMLElement
|
|
46
|
+
root: ReturnType<typeof createRoot>
|
|
47
|
+
}[] = []
|
|
48
|
+
|
|
49
|
+
const mount = async (ui: ReactNode) => {
|
|
50
|
+
const container = document.createElement("div")
|
|
51
|
+
|
|
52
|
+
document.body.append(container)
|
|
53
|
+
|
|
54
|
+
const root = createRoot(container)
|
|
55
|
+
|
|
56
|
+
await act(async () => {
|
|
57
|
+
root.render(ui)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
roots.push({ container, root })
|
|
61
|
+
|
|
62
|
+
return root
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
for (const { container, root } of roots.splice(0)) {
|
|
67
|
+
await act(async () => {
|
|
68
|
+
root.unmount()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
container.remove()
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The one component this file declares: it reports the live stack up
|
|
77
|
+
* through `onStack` and renders two panels whose visibility the test
|
|
78
|
+
* drives. `OverlayStackProvider` is rendered by the tests around it,
|
|
79
|
+
* so this stays a single component (the `no-multi-comp` rule) while
|
|
80
|
+
* still being a real consumer of the context.
|
|
81
|
+
*/
|
|
82
|
+
const Harness = ({
|
|
83
|
+
isAVisible = false,
|
|
84
|
+
isBVisible = false,
|
|
85
|
+
onStack,
|
|
86
|
+
}: {
|
|
87
|
+
isAVisible?: boolean
|
|
88
|
+
isBVisible?: boolean
|
|
89
|
+
onStack?: (stack: OverlayStackValue) => void
|
|
90
|
+
}): ReactNode => {
|
|
91
|
+
onStack?.(useOverlayStack())
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
<OverlayPanel
|
|
96
|
+
aria-label="A"
|
|
97
|
+
isVisible={isAVisible}
|
|
98
|
+
onClose={() => {}}
|
|
99
|
+
>
|
|
100
|
+
first
|
|
101
|
+
</OverlayPanel>
|
|
102
|
+
|
|
103
|
+
<OverlayPanel
|
|
104
|
+
aria-label="B"
|
|
105
|
+
isVisible={isBVisible}
|
|
106
|
+
onClose={() => {}}
|
|
107
|
+
>
|
|
108
|
+
second
|
|
109
|
+
</OverlayPanel>
|
|
110
|
+
</>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test("registers in order, closes the top, and toggles the scrim with depth", async () => {
|
|
115
|
+
const probe: { current: OverlayStackValue | null } = {
|
|
116
|
+
current: null,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await mount(
|
|
120
|
+
<OverlayStackProvider>
|
|
121
|
+
<Harness
|
|
122
|
+
onStack={(next) => {
|
|
123
|
+
probe.current = next
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
</OverlayStackProvider>,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const stack = () => {
|
|
130
|
+
if (!probe.current) {
|
|
131
|
+
throw new Error("probe never rendered")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return probe.current
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const scrim = () =>
|
|
138
|
+
document.body.querySelector(".bg-scrim")
|
|
139
|
+
|
|
140
|
+
expect(stack().depth).toBe(0)
|
|
141
|
+
expect(scrim()).toBeNull()
|
|
142
|
+
|
|
143
|
+
let closedTop = ""
|
|
144
|
+
|
|
145
|
+
await act(async () => {
|
|
146
|
+
stack().register({
|
|
147
|
+
id: "a",
|
|
148
|
+
onClose: () => {
|
|
149
|
+
closedTop = "a"
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(stack().depth).toBe(1)
|
|
155
|
+
expect(stack().topId).toBe("a")
|
|
156
|
+
// The scrim renders as long as at least one modal is open.
|
|
157
|
+
expect(scrim()).not.toBeNull()
|
|
158
|
+
|
|
159
|
+
await act(async () => {
|
|
160
|
+
stack().register({
|
|
161
|
+
id: "b",
|
|
162
|
+
onClose: () => {
|
|
163
|
+
closedTop = "b"
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(stack().depth).toBe(2)
|
|
169
|
+
// Last registered is the top.
|
|
170
|
+
expect(stack().topId).toBe("b")
|
|
171
|
+
|
|
172
|
+
// `requestCloseTop` targets the last, not the first.
|
|
173
|
+
await act(async () => {
|
|
174
|
+
stack().requestCloseTop()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(closedTop).toBe("b")
|
|
178
|
+
|
|
179
|
+
await act(async () => {
|
|
180
|
+
stack().unregister("b")
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// The next one down becomes the top, and the scrim stays.
|
|
184
|
+
expect(stack().topId).toBe("a")
|
|
185
|
+
expect(scrim()).not.toBeNull()
|
|
186
|
+
|
|
187
|
+
await act(async () => {
|
|
188
|
+
stack().unregister("a")
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Empty, so the scrim goes.
|
|
192
|
+
expect(stack().depth).toBe(0)
|
|
193
|
+
expect(scrim()).toBeNull()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("the scroll lock frees the page only when the last modal closes", async () => {
|
|
197
|
+
const original = document.documentElement.style.overflow
|
|
198
|
+
|
|
199
|
+
const root = await mount(
|
|
200
|
+
<OverlayStackProvider>
|
|
201
|
+
<Harness isAVisible isBVisible />
|
|
202
|
+
</OverlayStackProvider>,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
// Two open, one lock.
|
|
206
|
+
expect(document.documentElement.style.overflow).toBe(
|
|
207
|
+
"hidden",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
await act(async () => {
|
|
211
|
+
root.render(
|
|
212
|
+
<OverlayStackProvider>
|
|
213
|
+
<Harness isAVisible isBVisible={false} />
|
|
214
|
+
</OverlayStackProvider>,
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// One still open — the ref count has not reached zero.
|
|
219
|
+
expect(document.documentElement.style.overflow).toBe(
|
|
220
|
+
"hidden",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
await act(async () => {
|
|
224
|
+
root.render(
|
|
225
|
+
<OverlayStackProvider>
|
|
226
|
+
<Harness isAVisible={false} isBVisible={false} />
|
|
227
|
+
</OverlayStackProvider>,
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Empty, so the page is restored to exactly what it was.
|
|
232
|
+
expect(document.documentElement.style.overflow).toBe(
|
|
233
|
+
original,
|
|
234
|
+
)
|
|
235
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useLatestRef } from "@charcuterie/logic"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react"
|
|
10
|
+
|
|
11
|
+
import { SharedBackdrop } from "./SharedBackdrop.tsx"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The ordered stack of open **backdrop** modals, and the one shared
|
|
15
|
+
* scrim that stands behind all of them.
|
|
16
|
+
*
|
|
17
|
+
* ### Why a stack at all
|
|
18
|
+
*
|
|
19
|
+
* A modal must know two things it cannot see for itself: whether it is
|
|
20
|
+
* the **top** of the stack (only the top traps focus and answers
|
|
21
|
+
* Escape / an outside press; everything under it is `inert`), and how
|
|
22
|
+
* **deep** the stack is (the scrim shows while at least one modal is
|
|
23
|
+
* open and hides when the last closes). An `OverlayPanel` registers on
|
|
24
|
+
* open and unregisters on close, and reads `topId`/`depth` back.
|
|
25
|
+
*
|
|
26
|
+
* ### The provider is optional
|
|
27
|
+
*
|
|
28
|
+
* `useOverlayStack()` has a self-hosting default — `isProvided:
|
|
29
|
+
* false`, a no-op register, `depth: 0` — so a lone `Modal` needs no
|
|
30
|
+
* setup: it sees it is unprovided and renders its own scrim, treating
|
|
31
|
+
* itself as the top. Only *stacking* needs `OverlayStackProvider` at
|
|
32
|
+
* the app root, where a single shared scrim and real top-of-stack
|
|
33
|
+
* bookkeeping replace N self-hosted scrims fighting for the same
|
|
34
|
+
* corner.
|
|
35
|
+
*/
|
|
36
|
+
export type OverlayStackEntry = {
|
|
37
|
+
id: string
|
|
38
|
+
onClose: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type OverlayStackValue = {
|
|
42
|
+
depth: number
|
|
43
|
+
/**
|
|
44
|
+
* `false` in the context default. An `OverlayPanel` reads this to
|
|
45
|
+
* decide whether to render its own scrim (unprovided) or defer to
|
|
46
|
+
* the provider's shared one.
|
|
47
|
+
*/
|
|
48
|
+
isProvided: boolean
|
|
49
|
+
register: (entry: OverlayStackEntry) => void
|
|
50
|
+
/** Closes the top entry — the imperative twin of an outside press. */
|
|
51
|
+
requestCloseTop: () => void
|
|
52
|
+
topId: null | string
|
|
53
|
+
unregister: (id: string) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DEFAULT_STACK: OverlayStackValue = {
|
|
57
|
+
depth: 0,
|
|
58
|
+
isProvided: false,
|
|
59
|
+
register: () => {},
|
|
60
|
+
requestCloseTop: () => {},
|
|
61
|
+
topId: null,
|
|
62
|
+
unregister: () => {},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const OverlayStackContext =
|
|
66
|
+
createContext<OverlayStackValue>(DEFAULT_STACK)
|
|
67
|
+
|
|
68
|
+
export const useOverlayStack = (): OverlayStackValue =>
|
|
69
|
+
useContext(OverlayStackContext)
|
|
70
|
+
|
|
71
|
+
export const OverlayStackProvider = ({
|
|
72
|
+
children,
|
|
73
|
+
}: {
|
|
74
|
+
children: ReactNode
|
|
75
|
+
}): ReactNode => {
|
|
76
|
+
const [entries, setEntries] = useState<
|
|
77
|
+
OverlayStackEntry[]
|
|
78
|
+
>([])
|
|
79
|
+
|
|
80
|
+
// Read through a ref so `requestCloseTop` stays stable while still
|
|
81
|
+
// seeing the live top.
|
|
82
|
+
const entriesRef = useLatestRef(entries)
|
|
83
|
+
|
|
84
|
+
const register = useCallback(
|
|
85
|
+
(entry: OverlayStackEntry) => {
|
|
86
|
+
setEntries((previous) =>
|
|
87
|
+
// In place when the id is already present — an `onClose`
|
|
88
|
+
// update must not reorder the stack, or a re-render would
|
|
89
|
+
// shuffle which modal is "top". New ids append, which is what
|
|
90
|
+
// makes the last-registered one the top.
|
|
91
|
+
previous.some((one) => one.id === entry.id)
|
|
92
|
+
? previous.map((one) =>
|
|
93
|
+
one.id === entry.id ? entry : one,
|
|
94
|
+
)
|
|
95
|
+
: [...previous, entry],
|
|
96
|
+
)
|
|
97
|
+
},
|
|
98
|
+
[],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const unregister = useCallback((id: string) => {
|
|
102
|
+
setEntries((previous) =>
|
|
103
|
+
previous.filter((one) => one.id !== id),
|
|
104
|
+
)
|
|
105
|
+
}, [])
|
|
106
|
+
|
|
107
|
+
const requestCloseTop = useCallback(() => {
|
|
108
|
+
entriesRef.current.at(-1)?.onClose()
|
|
109
|
+
}, [entriesRef])
|
|
110
|
+
|
|
111
|
+
const value = useMemo<OverlayStackValue>(
|
|
112
|
+
() => ({
|
|
113
|
+
depth: entries.length,
|
|
114
|
+
isProvided: true,
|
|
115
|
+
register,
|
|
116
|
+
requestCloseTop,
|
|
117
|
+
topId: entries.at(-1)?.id ?? null,
|
|
118
|
+
unregister,
|
|
119
|
+
}),
|
|
120
|
+
[entries, register, requestCloseTop, unregister],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<OverlayStackContext.Provider value={value}>
|
|
125
|
+
{/* First, so it mounts its portal before the panels and paints
|
|
126
|
+
beneath them at an equal z-index. */}
|
|
127
|
+
<SharedBackdrop isVisible={entries.length > 0} />
|
|
128
|
+
|
|
129
|
+
{children}
|
|
130
|
+
</OverlayStackContext.Provider>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { FloatingPortal } from "@floating-ui/react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The scrim, and there is exactly one of it however many backdrop
|
|
6
|
+
* modals are stacked.
|
|
7
|
+
*
|
|
8
|
+
* Purely visual: `aria-hidden`, no handlers. Dismissal — a press
|
|
9
|
+
* anywhere off the top panel — is the top panel's own
|
|
10
|
+
* `useDismiss({ outsidePress })`, not a click on this node, which is
|
|
11
|
+
* what lets the whole package drop the `useKeyWithClickEvents`
|
|
12
|
+
* suppression the `<dialog>` backdrop needed. A press lands on the
|
|
13
|
+
* top panel's full-viewport centring layer (which sits above this),
|
|
14
|
+
* so this element never has to be the click target.
|
|
15
|
+
*
|
|
16
|
+
* Portalled to `document.body` on `--layer-overlay`, one step **below**
|
|
17
|
+
* the panels' `--layer-modal`, so it always paints behind every open
|
|
18
|
+
* modal and never intercepts a click meant for the dialog on top of it
|
|
19
|
+
* — a real bug a same-z-plus-DOM-order scheme has, because a
|
|
20
|
+
* later-mounted scrim then covers the panel. Stacking *between* panels
|
|
21
|
+
* still falls out of append order at the one `--layer-modal`.
|
|
22
|
+
*/
|
|
23
|
+
export const SharedBackdrop = ({
|
|
24
|
+
isVisible,
|
|
25
|
+
}: {
|
|
26
|
+
isVisible: boolean
|
|
27
|
+
}): ReactNode => {
|
|
28
|
+
if (!isVisible) {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<FloatingPortal>
|
|
34
|
+
<div
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
className="fixed inset-0 z-[var(--layer-overlay)] bg-scrim"
|
|
37
|
+
/>
|
|
38
|
+
</FloatingPortal>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The surface an anchored overlay's panel is drawn on — the four
|
|
3
|
+
* tokens `Popover` and `Menu` wrote out byte-identically, and that
|
|
4
|
+
* `Listbox` and `Combobox` are about to write a third and fourth
|
|
5
|
+
* time.
|
|
6
|
+
*
|
|
7
|
+
* Only the *surface* lives here: the raised background, the hairline
|
|
8
|
+
* border, the corner radius, and the elevation. Everything a panel
|
|
9
|
+
* does with that surface — a popover's `max-w-xs p-3`, a menu's
|
|
10
|
+
* `min-w-48 flex flex-col`, a listbox's scroll clamp — is the panel's
|
|
11
|
+
* own and stays at the call site, composed after this with
|
|
12
|
+
* `toClassName`. Pulling the shared half out is what keeps a change
|
|
13
|
+
* to the overlay surface (a new elevation token, say) from having to
|
|
14
|
+
* be made in four places and kept in agreement by hand.
|
|
15
|
+
*/
|
|
16
|
+
export const PANEL_SURFACE_CLASS =
|
|
17
|
+
"rounded-md border border-border-default bg-surface-overlay shadow-medium"
|