@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.
Files changed (116) hide show
  1. package/dist/Combobox/Combobox.d.ts +54 -0
  2. package/dist/Combobox/Combobox.d.ts.map +1 -0
  3. package/dist/Combobox/Combobox.js +216 -0
  4. package/dist/Combobox/Combobox.js.map +1 -0
  5. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  7. package/dist/Combobox/ComboboxOption.js +32 -0
  8. package/dist/Combobox/ComboboxOption.js.map +1 -0
  9. package/dist/Dialog/Dialog.d.ts +47 -0
  10. package/dist/Dialog/Dialog.d.ts.map +1 -0
  11. package/dist/Dialog/Dialog.js +52 -0
  12. package/dist/Dialog/Dialog.js.map +1 -0
  13. package/dist/Lightbox/Lightbox.d.ts +7 -7
  14. package/dist/Lightbox/Lightbox.js +9 -9
  15. package/dist/Lightbox/Lightbox.js.map +1 -1
  16. package/dist/Listbox/Listbox.d.ts +56 -0
  17. package/dist/Listbox/Listbox.d.ts.map +1 -0
  18. package/dist/Listbox/Listbox.js +151 -0
  19. package/dist/Listbox/Listbox.js.map +1 -0
  20. package/dist/Listbox/ListboxOption.d.ts +36 -0
  21. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  22. package/dist/Listbox/ListboxOption.js +42 -0
  23. package/dist/Listbox/ListboxOption.js.map +1 -0
  24. package/dist/Menu/Menu.d.ts.map +1 -1
  25. package/dist/Menu/Menu.js +47 -97
  26. package/dist/Menu/Menu.js.map +1 -1
  27. package/dist/Modal/Modal.d.ts +11 -59
  28. package/dist/Modal/Modal.d.ts.map +1 -1
  29. package/dist/Modal/Modal.js +28 -101
  30. package/dist/Modal/Modal.js.map +1 -1
  31. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  32. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  33. package/dist/Overlay/OverlayPanel.js +72 -0
  34. package/dist/Overlay/OverlayPanel.js.map +1 -0
  35. package/dist/Overlay/OverlayStack.d.ts +47 -0
  36. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  37. package/dist/Overlay/OverlayStack.js +46 -0
  38. package/dist/Overlay/OverlayStack.js.map +1 -0
  39. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  40. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  41. package/dist/Overlay/SharedBackdrop.js +28 -0
  42. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  43. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  44. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  45. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  46. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  47. package/dist/Overlay/overlayPanelClass.js +17 -0
  48. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  49. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  50. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  51. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  52. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  53. package/dist/Popover/Popover.d.ts +16 -24
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +32 -68
  56. package/dist/Popover/Popover.js.map +1 -1
  57. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  58. package/dist/Tooltip/Tooltip.js +25 -25
  59. package/dist/Tooltip/Tooltip.js.map +1 -1
  60. package/dist/index.d.ts +7 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/mountStory.testHelpers.d.ts +1 -0
  65. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  66. package/dist/mountStory.testHelpers.js +21 -1
  67. package/dist/mountStory.testHelpers.js.map +1 -1
  68. package/package.json +3 -2
  69. package/src/Combobox/Combobox.mdx +71 -0
  70. package/src/Combobox/Combobox.stories.tsx +245 -0
  71. package/src/Combobox/Combobox.test.tsx +289 -0
  72. package/src/Combobox/Combobox.tsx +513 -0
  73. package/src/Combobox/ComboboxOption.tsx +88 -0
  74. package/src/Dialog/Dialog.mdx +87 -0
  75. package/src/Dialog/Dialog.stories.tsx +347 -0
  76. package/src/Dialog/Dialog.test.tsx +312 -0
  77. package/src/Dialog/Dialog.tsx +127 -0
  78. package/src/Field/Field.test.tsx +6 -4
  79. package/src/Lightbox/Lightbox.mdx +9 -8
  80. package/src/Lightbox/Lightbox.stories.tsx +5 -5
  81. package/src/Lightbox/Lightbox.test.tsx +25 -36
  82. package/src/Lightbox/Lightbox.tsx +10 -10
  83. package/src/Listbox/Listbox.mdx +68 -0
  84. package/src/Listbox/Listbox.stories.tsx +205 -0
  85. package/src/Listbox/Listbox.test.tsx +198 -0
  86. package/src/Listbox/Listbox.tsx +295 -0
  87. package/src/Listbox/ListboxOption.tsx +104 -0
  88. package/src/Menu/Menu.mdx +11 -13
  89. package/src/Menu/Menu.test.tsx +47 -38
  90. package/src/Menu/Menu.tsx +81 -142
  91. package/src/Modal/Modal.mdx +29 -82
  92. package/src/Modal/Modal.stories.tsx +55 -188
  93. package/src/Modal/Modal.test.tsx +44 -177
  94. package/src/Modal/Modal.tsx +54 -204
  95. package/src/Overlay/OverlayPanel.tsx +195 -0
  96. package/src/Overlay/OverlayStack.test.tsx +235 -0
  97. package/src/Overlay/OverlayStack.tsx +132 -0
  98. package/src/Overlay/SharedBackdrop.tsx +40 -0
  99. package/src/Overlay/overlayPanelClass.ts +17 -0
  100. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  101. package/src/Popover/Popover.mdx +17 -20
  102. package/src/Popover/Popover.stories.tsx +33 -0
  103. package/src/Popover/Popover.test.tsx +68 -32
  104. package/src/Popover/Popover.tsx +56 -95
  105. package/src/Tooltip/Tooltip.mdx +12 -9
  106. package/src/Tooltip/Tooltip.test.tsx +17 -19
  107. package/src/Tooltip/Tooltip.tsx +35 -32
  108. package/src/index.ts +13 -4
  109. package/src/mountStory.testHelpers.ts +25 -1
  110. package/src/sourceRules.test.ts +12 -3
  111. package/src/storyControls.test.ts +25 -12
  112. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  113. package/dist/Modal/lockScrollBehind.js.map +0 -1
  114. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  115. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  116. /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
+ }
@@ -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. The top layer, the positioning strategy, and the ARIA wiring come
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
- ## `popover="manual"`, not a portal and not `auto`
42
+ ## Portalled, not the top layer
43
43
 
44
- `popover="manual"` puts the panel in the **top layer** — no `z-index` contest, and no
45
- `overflow: hidden` ancestor clipping it. That is what a portal is normally reached for,
46
- and unlike a portal the element **stays where it is in the DOM**:
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
- > `canvas.getByRole("dialog")` finds this panel. It would not find a portalled one, and
49
- > every story here would have to reach for `screen` and lose its scoping. Same argument as
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
- `manual` rather than `auto` is the state-ownership decision again. `auto` brings the UA's
53
- own light-dismiss, which closes the element by itself and leaves `isVisible` true.
54
- `useDismiss` does the same job through our callback, so Charcuterie stays the only thing
55
- that decides.
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
- ### Two UA defaults that must be undone
58
-
59
- `[popover]` ships `position: fixed; inset: 0; margin: auto`. Left in place it centres the
60
- panel in the viewport and fights every coordinate floating-ui computes — and it looks
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