@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
|
@@ -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"
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useClonedChild,
|
|
3
|
+
useUniqueId,
|
|
4
|
+
} from "@charcuterie/logic"
|
|
5
|
+
import type { Placement } from "@floating-ui/react"
|
|
6
|
+
import {
|
|
7
|
+
autoUpdate,
|
|
8
|
+
flip,
|
|
9
|
+
offset,
|
|
10
|
+
shift,
|
|
11
|
+
size,
|
|
12
|
+
useDismiss,
|
|
13
|
+
useFloating,
|
|
14
|
+
useInteractions,
|
|
15
|
+
useRole,
|
|
16
|
+
} from "@floating-ui/react"
|
|
17
|
+
import type { ReactElement } from "react"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The floating-ui block every anchored overlay — `Popover`, `Menu`,
|
|
21
|
+
* `Listbox`, `Combobox` — wrote out for itself, consolidated once.
|
|
22
|
+
*
|
|
23
|
+
* `Popover` and `Menu` had the same twelve-line `useFloating` +
|
|
24
|
+
* `useInteractions` + `useClonedChild` preamble with two constants
|
|
25
|
+
* different (the offset and the role), and the fleet's four pickers
|
|
26
|
+
* hand-rolled a fifth, sixth, seventh and eighth `computePosition`
|
|
27
|
+
* that reimplemented `flip`/`shift` against `window.innerHeight`.
|
|
28
|
+
* This is the one implementation they all take.
|
|
29
|
+
*
|
|
30
|
+
* ### The owner's "configurable, but those are the basics"
|
|
31
|
+
*
|
|
32
|
+
* Escape and outside-press both close, **on by default**, for every
|
|
33
|
+
* anchored overlay — that is what an overlay is. A component switches
|
|
34
|
+
* one off through `dismiss` (a `Combobox`'s Escape clears the query
|
|
35
|
+
* before it closes; a menu opened from a context click wants no
|
|
36
|
+
* outside-press race with the thing that spawned it), but it opts out
|
|
37
|
+
* rather than opting in.
|
|
38
|
+
*
|
|
39
|
+
* ### `size`, for the pickers
|
|
40
|
+
*
|
|
41
|
+
* `matchTriggerWidth` and `maxHeightPx` are the two things
|
|
42
|
+
* `PortalDropdown` did with a hand-measured `TriggerRect` and a
|
|
43
|
+
* `window.innerHeight` clamp. The `size` middleware does both from the
|
|
44
|
+
* platform: the panel takes the trigger's width, and it never grows
|
|
45
|
+
* past the space actually left in the viewport.
|
|
46
|
+
*
|
|
47
|
+
* The computed width/height are written **straight onto the floating
|
|
48
|
+
* element's style** rather than lifted into React state, which is
|
|
49
|
+
* floating-ui's own documented shape and is deliberate twice over: a
|
|
50
|
+
* `setState` inside `apply` re-runs the position, which re-runs
|
|
51
|
+
* `apply`, which is a render loop; and because these properties never
|
|
52
|
+
* enter the `floatingStyles` object React manages, a component
|
|
53
|
+
* re-render (a keystroke in a combobox) does not wipe them — only an
|
|
54
|
+
* `autoUpdate` tick recomputes them.
|
|
55
|
+
*
|
|
56
|
+
* ### Portal-agnostic
|
|
57
|
+
*
|
|
58
|
+
* This returns the pieces — `clonedTrigger`, `getFloatingProps`,
|
|
59
|
+
* `setFloating`, `floatingStyles`, `context` — and renders nothing.
|
|
60
|
+
* Whether the panel goes in a `FloatingPortal`, the top layer, or in
|
|
61
|
+
* place is the component's call, which is what lets the same hook back
|
|
62
|
+
* a portalled `Listbox` and (for now) a top-layer `Popover`.
|
|
63
|
+
*/
|
|
64
|
+
export type AnchoredOverlayRole =
|
|
65
|
+
| "dialog"
|
|
66
|
+
| "listbox"
|
|
67
|
+
| "menu"
|
|
68
|
+
|
|
69
|
+
export type UseAnchoredOverlayOptions = {
|
|
70
|
+
/**
|
|
71
|
+
* Both default **on** — Escape and outside-press are what makes an
|
|
72
|
+
* overlay an overlay. Switch one off for the honest exception (a
|
|
73
|
+
* combobox that swallows the first Escape to clear its query), never
|
|
74
|
+
* to make a plain overlay sticky. Named for the flattened
|
|
75
|
+
* floating-ui `useDismiss` options they carry.
|
|
76
|
+
*/
|
|
77
|
+
isEscapeDismissable?: boolean
|
|
78
|
+
isOutsidePressDismissable?: boolean
|
|
79
|
+
/** Ports `PortalDropdown`'s anchor-width match, via `size`. */
|
|
80
|
+
isTriggerWidthMatched?: boolean
|
|
81
|
+
isVisible: boolean
|
|
82
|
+
/** Clamps the panel to `min(this, available viewport space)`. */
|
|
83
|
+
maxHeightPx?: number
|
|
84
|
+
/** Popover 8, Menu/Listbox/Combobox 4. */
|
|
85
|
+
offsetValue?: number
|
|
86
|
+
onDismiss: () => void
|
|
87
|
+
placement?: Placement
|
|
88
|
+
role: AnchoredOverlayRole
|
|
89
|
+
/** The control the panel hangs off. **Cloned, not wrapped.** */
|
|
90
|
+
trigger: ReactElement
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const useAnchoredOverlay = ({
|
|
94
|
+
isEscapeDismissable = true,
|
|
95
|
+
isOutsidePressDismissable = true,
|
|
96
|
+
isTriggerWidthMatched = false,
|
|
97
|
+
isVisible,
|
|
98
|
+
maxHeightPx,
|
|
99
|
+
offsetValue = 8,
|
|
100
|
+
onDismiss,
|
|
101
|
+
placement = "bottom-start",
|
|
102
|
+
role,
|
|
103
|
+
trigger,
|
|
104
|
+
}: UseAnchoredOverlayOptions) => {
|
|
105
|
+
const middleware = [
|
|
106
|
+
offset(offsetValue),
|
|
107
|
+
flip(),
|
|
108
|
+
shift({ padding: 8 }),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
if (isTriggerWidthMatched || maxHeightPx !== undefined) {
|
|
112
|
+
middleware.push(
|
|
113
|
+
size({
|
|
114
|
+
padding: 8,
|
|
115
|
+
apply({ availableHeight, elements, rects }) {
|
|
116
|
+
const cap =
|
|
117
|
+
maxHeightPx === undefined
|
|
118
|
+
? availableHeight
|
|
119
|
+
: Math.min(maxHeightPx, availableHeight)
|
|
120
|
+
|
|
121
|
+
elements.floating.style.maxHeight = `${cap}px`
|
|
122
|
+
|
|
123
|
+
if (isTriggerWidthMatched) {
|
|
124
|
+
elements.floating.style.width = `${rects.reference.width}px`
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { context, floatingStyles, refs } = useFloating({
|
|
132
|
+
// Read-only. Charcuterie remains the sole owner; floating-ui is
|
|
133
|
+
// told what is true and never decides it.
|
|
134
|
+
open: isVisible,
|
|
135
|
+
onOpenChange: (isNextVisible) => {
|
|
136
|
+
if (!isNextVisible) {
|
|
137
|
+
onDismiss()
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
placement,
|
|
141
|
+
middleware,
|
|
142
|
+
strategy: "fixed",
|
|
143
|
+
whileElementsMounted: autoUpdate,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// `useRole` writes `aria-expanded`/`aria-haspopup`/`aria-controls`
|
|
147
|
+
// on the trigger and `id` + `role` on the panel from one source —
|
|
148
|
+
// which is what keeps `aria-controls` pointing at the panel's real
|
|
149
|
+
// id across the portal boundary, the objection the top-layer
|
|
150
|
+
// decision raised and this answers.
|
|
151
|
+
const { getFloatingProps, getReferenceProps } =
|
|
152
|
+
useInteractions([
|
|
153
|
+
useDismiss(context, {
|
|
154
|
+
escapeKey: isEscapeDismissable,
|
|
155
|
+
outsidePress: isOutsidePressDismissable,
|
|
156
|
+
}),
|
|
157
|
+
useRole(context, { role }),
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
// `useRole` for a `listbox` overwrites the trigger's own role with
|
|
161
|
+
// `combobox` — the select-only-combobox pattern, which then demands
|
|
162
|
+
// its own `aria-label`. The fleet's pattern is a plain button that
|
|
163
|
+
// *opens* a listbox, so the injected `role` is dropped and the
|
|
164
|
+
// trigger keeps its native semantics while still gaining
|
|
165
|
+
// `aria-haspopup="listbox"`/`aria-expanded`/`aria-controls`. `useRole`
|
|
166
|
+
// injects a reference `role` only for listbox/combobox, so this is a
|
|
167
|
+
// no-op for `Popover` (dialog) and `Menu` (menu).
|
|
168
|
+
const { role: _discardedTriggerRole, ...referenceProps } =
|
|
169
|
+
getReferenceProps()
|
|
170
|
+
|
|
171
|
+
// A stable id on the trigger, so a panel that needs a name can point
|
|
172
|
+
// `aria-labelledby` at it across the portal. `useRole` already gives
|
|
173
|
+
// a `menu`'s reference an id (and labels the panel with it); this
|
|
174
|
+
// reuses that when present and supplies one otherwise — which is what
|
|
175
|
+
// a `listbox` panel needs, since a bare `role="listbox"` is an ARIA
|
|
176
|
+
// input field and must be named.
|
|
177
|
+
const generatedTriggerId = useUniqueId()
|
|
178
|
+
|
|
179
|
+
const triggerId =
|
|
180
|
+
(referenceProps.id as string | undefined) ??
|
|
181
|
+
generatedTriggerId
|
|
182
|
+
|
|
183
|
+
const clonedTrigger = useClonedChild(trigger, {
|
|
184
|
+
...referenceProps,
|
|
185
|
+
id: triggerId,
|
|
186
|
+
ref: refs.setReference,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
clonedTrigger,
|
|
191
|
+
context,
|
|
192
|
+
floatingStyles,
|
|
193
|
+
getFloatingProps,
|
|
194
|
+
setFloating: refs.setFloating,
|
|
195
|
+
triggerId,
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/Popover/Popover.mdx
CHANGED
|
@@ -32,35 +32,32 @@ useFloating({
|
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
It is used for the three things only it can do — collision handling, a real dismiss layer,
|
|
35
|
-
and focus management
|
|
36
|
-
from the platform and from `useRole`.
|
|
35
|
+
and focus management — all consolidated in `useAnchoredOverlay`. The positioning strategy
|
|
36
|
+
and the ARIA wiring come from the platform and from `useRole`.
|
|
37
37
|
|
|
38
38
|
Passing `open:` *into* floating-ui does not violate the `is`/`has` rule; the adapter
|
|
39
39
|
boundary is exactly the carve-out that rule already has
|
|
40
40
|
([decision](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)).
|
|
41
41
|
|
|
42
|
-
##
|
|
42
|
+
## Portalled, not the top layer
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
The panel renders in a `FloatingPortal` at `document.body`. `popover="manual"` was reached
|
|
45
|
+
for first — no `z-index` contest, no portal — but the top layer does not escape an
|
|
46
|
+
`overflow: hidden` ancestor's *clipping* in every case the fleet hit, and a portal does
|
|
47
|
+
([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)).
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
> `Modal`, one layer down.
|
|
49
|
+
The objection a portal used to raise — that it moves the panel out of a scoped `canvas`
|
|
50
|
+
query — is answered:
|
|
51
51
|
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
> `useRole` writes `aria-controls` on the trigger pointing at the panel `id`, so
|
|
53
|
+
> trigger→panel stays followable across the boundary (the test asserts it). And the story
|
|
54
|
+
> scopes panel queries to `within(document.body)` while `expectAgentDrivable` — structural
|
|
55
|
+
> — needs no change.
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
deliberate, which is the worst kind of bug. `inset-auto m-0` puts it back, and
|
|
62
|
-
`strategy: "fixed"` keeps the numbers viewport-relative like the top layer itself.
|
|
63
|
-
`AllVariants` asserts the panel is positioned at all, for exactly this reason.
|
|
57
|
+
`useDismiss` owns Escape and outside-press through our callback, so Charcuterie stays the
|
|
58
|
+
only thing that decides `isVisible`. `strategy: "fixed"` keeps the coordinates
|
|
59
|
+
viewport-relative; `AllVariants` asserts the panel is positioned at all, for exactly this
|
|
60
|
+
reason.
|
|
64
61
|
|
|
65
62
|
<Canvas of={PopoverStories.AllVariants} />
|
|
66
63
|
|