@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.
Files changed (134) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
  4. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
  8. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
  9. package/dist/Combobox/Combobox.d.ts +54 -0
  10. package/dist/Combobox/Combobox.d.ts.map +1 -0
  11. package/dist/Combobox/Combobox.js +216 -0
  12. package/dist/Combobox/Combobox.js.map +1 -0
  13. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  14. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  15. package/dist/Combobox/ComboboxOption.js +32 -0
  16. package/dist/Combobox/ComboboxOption.js.map +1 -0
  17. package/dist/Dialog/Dialog.d.ts +47 -0
  18. package/dist/Dialog/Dialog.d.ts.map +1 -0
  19. package/dist/Dialog/Dialog.js +52 -0
  20. package/dist/Dialog/Dialog.js.map +1 -0
  21. package/dist/Lightbox/Lightbox.d.ts +57 -0
  22. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  23. package/dist/Lightbox/Lightbox.js +50 -0
  24. package/dist/Lightbox/Lightbox.js.map +1 -0
  25. package/dist/Listbox/Listbox.d.ts +56 -0
  26. package/dist/Listbox/Listbox.d.ts.map +1 -0
  27. package/dist/Listbox/Listbox.js +151 -0
  28. package/dist/Listbox/Listbox.js.map +1 -0
  29. package/dist/Listbox/ListboxOption.d.ts +36 -0
  30. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  31. package/dist/Listbox/ListboxOption.js +42 -0
  32. package/dist/Listbox/ListboxOption.js.map +1 -0
  33. package/dist/Menu/Menu.d.ts.map +1 -1
  34. package/dist/Menu/Menu.js +47 -97
  35. package/dist/Menu/Menu.js.map +1 -1
  36. package/dist/Modal/Modal.d.ts +11 -59
  37. package/dist/Modal/Modal.d.ts.map +1 -1
  38. package/dist/Modal/Modal.js +28 -101
  39. package/dist/Modal/Modal.js.map +1 -1
  40. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  41. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  42. package/dist/Overlay/OverlayPanel.js +72 -0
  43. package/dist/Overlay/OverlayPanel.js.map +1 -0
  44. package/dist/Overlay/OverlayStack.d.ts +47 -0
  45. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  46. package/dist/Overlay/OverlayStack.js +46 -0
  47. package/dist/Overlay/OverlayStack.js.map +1 -0
  48. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  49. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  50. package/dist/Overlay/SharedBackdrop.js +28 -0
  51. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  52. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  53. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  54. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  55. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  56. package/dist/Overlay/overlayPanelClass.js +17 -0
  57. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  58. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  59. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  60. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  61. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  62. package/dist/Popover/Popover.d.ts +16 -24
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +32 -68
  65. package/dist/Popover/Popover.js.map +1 -1
  66. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/Tooltip.js +25 -25
  68. package/dist/Tooltip/Tooltip.js.map +1 -1
  69. package/dist/index.d.ts +13 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +6 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/mountStory.testHelpers.d.ts +1 -0
  74. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  75. package/dist/mountStory.testHelpers.js +21 -1
  76. package/dist/mountStory.testHelpers.js.map +1 -1
  77. package/package.json +5 -4
  78. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  79. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  80. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  81. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  82. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  83. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  84. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  85. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  86. package/src/Combobox/Combobox.mdx +71 -0
  87. package/src/Combobox/Combobox.stories.tsx +245 -0
  88. package/src/Combobox/Combobox.test.tsx +289 -0
  89. package/src/Combobox/Combobox.tsx +513 -0
  90. package/src/Combobox/ComboboxOption.tsx +88 -0
  91. package/src/Dialog/Dialog.mdx +87 -0
  92. package/src/Dialog/Dialog.stories.tsx +347 -0
  93. package/src/Dialog/Dialog.test.tsx +312 -0
  94. package/src/Dialog/Dialog.tsx +127 -0
  95. package/src/Field/Field.test.tsx +6 -4
  96. package/src/Lightbox/Lightbox.mdx +63 -0
  97. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  98. package/src/Lightbox/Lightbox.test.tsx +124 -0
  99. package/src/Lightbox/Lightbox.tsx +143 -0
  100. package/src/Listbox/Listbox.mdx +68 -0
  101. package/src/Listbox/Listbox.stories.tsx +205 -0
  102. package/src/Listbox/Listbox.test.tsx +198 -0
  103. package/src/Listbox/Listbox.tsx +295 -0
  104. package/src/Listbox/ListboxOption.tsx +104 -0
  105. package/src/Menu/Menu.mdx +11 -13
  106. package/src/Menu/Menu.test.tsx +47 -38
  107. package/src/Menu/Menu.tsx +81 -142
  108. package/src/Modal/Modal.mdx +29 -82
  109. package/src/Modal/Modal.stories.tsx +55 -188
  110. package/src/Modal/Modal.test.tsx +44 -177
  111. package/src/Modal/Modal.tsx +54 -204
  112. package/src/Overlay/OverlayPanel.tsx +195 -0
  113. package/src/Overlay/OverlayStack.test.tsx +235 -0
  114. package/src/Overlay/OverlayStack.tsx +132 -0
  115. package/src/Overlay/SharedBackdrop.tsx +40 -0
  116. package/src/Overlay/overlayPanelClass.ts +17 -0
  117. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  118. package/src/Popover/Popover.mdx +17 -20
  119. package/src/Popover/Popover.stories.tsx +33 -0
  120. package/src/Popover/Popover.test.tsx +68 -32
  121. package/src/Popover/Popover.tsx +56 -95
  122. package/src/Tooltip/Tooltip.mdx +12 -9
  123. package/src/Tooltip/Tooltip.test.tsx +17 -19
  124. package/src/Tooltip/Tooltip.tsx +35 -32
  125. package/src/icons.storyHelpers.tsx +22 -0
  126. package/src/index.ts +22 -4
  127. package/src/mountStory.testHelpers.ts +25 -1
  128. package/src/sourceRules.test.ts +26 -1
  129. package/src/storyControls.test.ts +25 -12
  130. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  131. package/dist/Modal/lockScrollBehind.js.map +0 -1
  132. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  133. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  134. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -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
 
@@ -245,3 +245,36 @@ export const Interactive: Story = {
245
245
  />
246
246
  ),
247
247
  }
248
+
249
+ /**
250
+ * The bug the whole M8 portal reversal exists to fix. The trigger sits
251
+ * inside a small `overflow: hidden` box — the top layer was still
252
+ * clipped by it; a `FloatingPortal` to `document.body` is not. Open it
253
+ * and the panel escapes the clip whole.
254
+ */
255
+ export const EscapesOverflowClip: Story = {
256
+ args: {
257
+ children: null,
258
+ heading: "Filters",
259
+ isVisible: false,
260
+ onDismiss: () => {},
261
+ trigger: <Button>Filters</Button>,
262
+ },
263
+ render: () => (
264
+ <div className="h-24 w-56 overflow-hidden rounded-md border border-border-default bg-surface-raised p-3">
265
+ <p className="mb-2 text-content-secondary text-xs">
266
+ This box is `overflow: hidden`.
267
+ </p>
268
+
269
+ <PopoverDemo
270
+ heading="Escapes the clip"
271
+ triggerLabel="Open (escapes the clip)"
272
+ >
273
+ <p className="text-content-secondary">
274
+ Portalled to the body, so the clip cannot reach
275
+ it.
276
+ </p>
277
+ </PopoverDemo>
278
+ </div>
279
+ ),
280
+ }
@@ -11,13 +11,13 @@ const {
11
11
  AllStates,
12
12
  AllVariants,
13
13
  Default,
14
+ EscapesOverflowClip,
14
15
  Interactive,
15
16
  Responsive,
16
17
  } = composeStories(stories)
17
18
 
18
19
  test("the trigger and the panel agree about each other", async () => {
19
- const { canvas, canvasElement } =
20
- await mountStory(Default)
20
+ const { body, canvas } = await mountStory(Default)
21
21
 
22
22
  const trigger = expectAgentDrivable(canvas, {
23
23
  name: "Filters",
@@ -31,31 +31,31 @@ test("the trigger and the panel agree about each other", async () => {
31
31
 
32
32
  await userEvent.click(trigger)
33
33
 
34
- // Found through `canvas`, not `screen` — the panel is in the top
35
- // layer and still in this story's DOM, which a portal would have
36
- // cost.
37
- const panel = expectAgentDrivable(canvas, {
34
+ // Found through `body`, not `canvas` — the panel portals to
35
+ // `document.body`, which is the whole point of portalling over the
36
+ // top layer. The trigger stays in the canvas.
37
+ const panel = expectAgentDrivable(body, {
38
38
  name: "Filters",
39
39
  role: "dialog",
40
40
  })
41
41
 
42
- // `aria-controls` really points at the panel. An id that has
43
- // drifted renders identically and announces nothing, which is why
44
- // `useRole` owns both ends of it.
42
+ // `aria-controls` really points at the panel across the portal
43
+ // boundary — the direct answer to the superseded top-layer
44
+ // decision's one real objection. An id that has drifted renders
45
+ // identically and announces nothing, which is why `useRole` owns
46
+ // both ends of it.
45
47
  await expect(trigger).toHaveAttribute(
46
48
  "aria-controls",
47
49
  panel.id,
48
50
  )
49
51
 
50
- // `manual`, not `auto`: the UA's light-dismiss closes the element
51
- // by itself and leaves `isVisible` true.
52
- await expect(panel).toHaveAttribute("popover", "manual")
53
-
54
- await expectNoAxeViolations(canvasElement)
52
+ // The open panel audits itself; the a11y addon's own pass fires on
53
+ // the canvas, which the panel has now left.
54
+ await expectNoAxeViolations(panel)
55
55
  })
56
56
 
57
57
  test("the panel is positioned rather than centred by the UA", async () => {
58
- const { canvas } = await mountStory(AllVariants)
58
+ const { body, canvas } = await mountStory(AllVariants)
59
59
 
60
60
  await userEvent.click(
61
61
  expectAgentDrivable(canvas, {
@@ -64,14 +64,14 @@ test("the panel is positioned rather than centred by the UA", async () => {
64
64
  }),
65
65
  )
66
66
 
67
- const panel = expectAgentDrivable(canvas, {
67
+ const panel = expectAgentDrivable(body, {
68
68
  name: "Filters — top",
69
69
  role: "dialog",
70
70
  })
71
71
 
72
- // The thing that silently fails if the UA's `inset: 0; margin:
73
- // auto` is left in place: the panel would sit dead centre of the
74
- // viewport looking deliberate.
72
+ // The thing that silently fails if a portalled panel is left at its
73
+ // default static position: it would sit at the top-left of the
74
+ // document rather than anchored to its trigger.
75
75
  const { left, top } = panel.getBoundingClientRect()
76
76
 
77
77
  await expect(left).toBeGreaterThan(0)
@@ -80,7 +80,7 @@ test("the panel is positioned rather than centred by the UA", async () => {
80
80
  })
81
81
 
82
82
  test("a panel with nothing tabbable inside is still reachable", async () => {
83
- const { canvas } = await mountStory(AllStates)
83
+ const { body, canvas } = await mountStory(AllStates)
84
84
 
85
85
  await userEvent.click(
86
86
  expectAgentDrivable(canvas, {
@@ -89,7 +89,7 @@ test("a panel with nothing tabbable inside is still reachable", async () => {
89
89
  }),
90
90
  )
91
91
 
92
- const panel = expectAgentDrivable(canvas, {
92
+ const panel = expectAgentDrivable(body, {
93
93
  name: "Read error detail",
94
94
  role: "dialog",
95
95
  })
@@ -108,7 +108,7 @@ test("a panel with nothing tabbable inside is still reachable", async () => {
108
108
  })
109
109
 
110
110
  test("flip and shift keep the panel inside the viewport", async () => {
111
- const { canvas } = await mountStory(Responsive)
111
+ const { body, canvas } = await mountStory(Responsive)
112
112
 
113
113
  await userEvent.click(
114
114
  expectAgentDrivable(canvas, {
@@ -117,7 +117,7 @@ test("flip and shift keep the panel inside the viewport", async () => {
117
117
  }),
118
118
  )
119
119
 
120
- const panel = expectAgentDrivable(canvas, {
120
+ const panel = expectAgentDrivable(body, {
121
121
  name: "Filters — top start",
122
122
  role: "dialog",
123
123
  })
@@ -145,13 +145,13 @@ test("flip and shift keep the panel inside the viewport", async () => {
145
145
  * through `useDismiss` → `onDismiss` → `hide()`, so the panel is
146
146
  * never closed by anything except the state saying so.
147
147
  *
148
- * Escape *is* pressable here, unlike `Modal`'s: `useDismiss` listens
149
- * for a keydown rather than relying on a UA default action, and a
150
- * synthetic keydown is a real keydown as far as a listener is
151
- * concerned.
148
+ * Escape *is* pressable here, unlike the old native-`<dialog>`
149
+ * `Modal`'s: `useDismiss` listens for a keydown rather than relying on
150
+ * a UA default action, and a synthetic keydown is a real keydown as
151
+ * far as a listener is concerned.
152
152
  */
153
153
  test("Escape and an outside press both dismiss through the state", async () => {
154
- const { canvas } = await mountStory(Interactive)
154
+ const { body, canvas } = await mountStory(Interactive)
155
155
 
156
156
  const trigger = expectAgentDrivable(canvas, {
157
157
  name: "Filters",
@@ -160,7 +160,7 @@ test("Escape and an outside press both dismiss through the state", async () => {
160
160
 
161
161
  await userEvent.click(trigger)
162
162
 
163
- expectAgentDrivable(canvas, {
163
+ expectAgentDrivable(body, {
164
164
  name: "Bay 3 filters",
165
165
  role: "dialog",
166
166
  })
@@ -168,7 +168,7 @@ test("Escape and an outside press both dismiss through the state", async () => {
168
168
  await userEvent.keyboard("{Escape}")
169
169
 
170
170
  await waitFor(() => {
171
- expect(canvas.queryByRole("dialog")).toBeNull()
171
+ expect(body.queryByRole("dialog")).toBeNull()
172
172
  })
173
173
 
174
174
  await expect(trigger).toHaveAttribute(
@@ -180,12 +180,48 @@ test("Escape and an outside press both dismiss through the state", async () => {
180
180
  await userEvent.click(trigger)
181
181
 
182
182
  await waitFor(() => {
183
- expect(canvas.getByRole("dialog")).toBeInTheDocument()
183
+ expect(body.getByRole("dialog")).toBeInTheDocument()
184
184
  })
185
185
 
186
186
  await userEvent.click(document.body)
187
187
 
188
188
  await waitFor(() => {
189
- expect(canvas.queryByRole("dialog")).toBeNull()
189
+ expect(body.queryByRole("dialog")).toBeNull()
190
+ })
191
+ })
192
+
193
+ /**
194
+ * The bug the whole M8 portal reversal exists to fix: the top layer was
195
+ * still clipped by an `overflow: hidden` ancestor, and a portal to
196
+ * `document.body` is not. The proof is structural — the panel is not a
197
+ * descendant of the clipping box at all.
198
+ */
199
+ test("the panel escapes an overflow:hidden ancestor", async () => {
200
+ const { body, canvas, canvasElement } = await mountStory(
201
+ EscapesOverflowClip,
202
+ )
203
+
204
+ const clipBox = canvasElement.querySelector(
205
+ ".overflow-hidden",
206
+ )
207
+
208
+ await userEvent.click(
209
+ expectAgentDrivable(canvas, {
210
+ name: "Open (escapes the clip)",
211
+ role: "button",
212
+ }),
213
+ )
214
+
215
+ const panel = expectAgentDrivable(body, {
216
+ name: "Escapes the clip",
217
+ role: "dialog",
190
218
  })
219
+
220
+ // Portalled out of the clipping subtree entirely — so nothing clips
221
+ // it — while the trigger stays inside it.
222
+ await expect(clipBox).not.toBeNull()
223
+
224
+ await expect(clipBox?.contains(panel)).toBe(false)
225
+
226
+ await expect(document.body.contains(panel)).toBe(true)
191
227
  })
@@ -1,18 +1,12 @@
1
- import { useClonedChild } from "@charcuterie/logic"
2
1
  import type { Placement } from "@floating-ui/react"
3
2
  import {
4
- autoUpdate,
5
3
  FloatingFocusManager,
6
- flip,
7
- offset,
8
- shift,
9
- useDismiss,
10
- useFloating,
11
- useInteractions,
12
- useRole,
4
+ FloatingPortal,
13
5
  } from "@floating-ui/react"
14
6
  import type { ReactElement, ReactNode } from "react"
15
7
 
8
+ import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
9
+ import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
16
10
  import { toClassName } from "../toClassName.ts"
17
11
 
18
12
  export type PopoverProps = {
@@ -51,32 +45,24 @@ export type PopoverProps = {
51
45
  *
52
46
  * What floating-ui is used for is what only it can do: collision
53
47
  * handling (`flip`, `shift`), a real dismiss layer, and focus
54
- * management. Positioning strategy, the top layer, and the ARIA
55
- * wiring come from the platform and from `useRole`.
48
+ * management — all of it consolidated in `useAnchoredOverlay`.
49
+ * Positioning strategy and the ARIA wiring come from the platform and
50
+ * from `useRole`.
56
51
  *
57
- * ### The top layer, from the platform — not a portal
52
+ * ### Portalled, not the top layer
58
53
  *
59
- * `popover="manual"` puts the panel in the top layer, which is
60
- * what a portal is normally reached for: no `z-index` contest, and
61
- * no `overflow: hidden` ancestor clipping it. Unlike a portal it
62
- * **stays where it is in the DOM**, so `canvas.getByRole("dialog")`
63
- * still finds it and a story keeps its scoping. Same argument as
64
- * `Modal`, one layer down.
65
- *
66
- * `manual`, not `auto`, and that is the state-ownership decision
67
- * again: `auto` brings the UA's own light-dismiss, which closes the
68
- * element by itself and leaves `isVisible` true. `useDismiss` does
69
- * the same job through our callback, so Charcuterie stays the only
70
- * thing that decides.
71
- *
72
- * ### Two UA defaults that have to be undone
73
- *
74
- * `[popover]` ships `position: fixed; inset: 0; margin: auto`,
75
- * which centres the panel in the viewport and fights every
76
- * coordinate floating-ui computes — hence `inset-auto m-0`, and
77
- * `strategy: "fixed"` so the numbers are viewport-relative like the
78
- * top layer itself. Getting either wrong reads as a positioning
79
- * bug rather than as a UA default.
54
+ * The panel goes in a `FloatingPortal` to `document.body`. The top
55
+ * layer (`popover="manual"`) was reached for first — it needs no
56
+ * portal and no `z-index` — but it does not escape an
57
+ * `overflow: hidden` ancestor's *clipping* in every case the fleet
58
+ * hit, and a portal does. The objection a portal used to raise, that
59
+ * it moves the panel out of a scoped `canvas` query, is answered two
60
+ * ways: `useRole` writes `aria-controls` on the trigger pointing at
61
+ * the panel `id`, so trigger→panel stays followable across the
62
+ * boundary; and the story scopes panel queries to
63
+ * `within(document.body)` while the structural `expectAgentDrivable`
64
+ * needs no change. `strategy: "fixed"` keeps the coordinates
65
+ * viewport-relative.
80
66
  */
81
67
  export const Popover = ({
82
68
  children,
@@ -87,34 +73,19 @@ export const Popover = ({
87
73
  placement = "bottom-start",
88
74
  trigger,
89
75
  }: PopoverProps): ReactNode => {
90
- const { context, floatingStyles, refs } = useFloating({
91
- // Read-only. Charcuterie remains the sole owner; floating-ui
92
- // is told what is true and never decides it.
93
- open: isVisible,
94
- onOpenChange: (isNextVisible) => {
95
- if (!isNextVisible) {
96
- onDismiss()
97
- }
98
- },
76
+ const {
77
+ clonedTrigger,
78
+ context,
79
+ floatingStyles,
80
+ getFloatingProps,
81
+ setFloating,
82
+ } = useAnchoredOverlay({
83
+ isVisible,
84
+ offsetValue: 8,
85
+ onDismiss,
99
86
  placement,
100
- middleware: [offset(8), flip(), shift({ padding: 8 })],
101
- strategy: "fixed",
102
- whileElementsMounted: autoUpdate,
103
- })
104
-
105
- // `useRole` writes `aria-expanded`, `aria-haspopup`, and
106
- // `aria-controls` on the trigger and `id` + `role="dialog"` on
107
- // the panel, from one source. Hand-writing them alongside is how
108
- // an `aria-controls` ends up pointing at an id that moved.
109
- const { getFloatingProps, getReferenceProps } =
110
- useInteractions([
111
- useDismiss(context),
112
- useRole(context, { role: "dialog" }),
113
- ])
114
-
115
- const clonedTrigger = useClonedChild(trigger, {
116
- ...getReferenceProps(),
117
- ref: refs.setReference,
87
+ role: "dialog",
88
+ trigger,
118
89
  })
119
90
 
120
91
  return (
@@ -122,42 +93,32 @@ export const Popover = ({
122
93
  {clonedTrigger}
123
94
 
124
95
  {isVisible ? (
125
- <FloatingFocusManager
126
- context={context}
127
- modal={false}
128
- >
129
- <div
130
- {...getFloatingProps()}
131
- aria-label={heading}
132
- className={toClassName(
133
- "inset-auto m-0 max-w-xs rounded-md border border-border-default bg-surface-overlay p-3 text-content-primary text-sm shadow-medium",
134
- className,
135
- )}
136
- popover="manual"
137
- // Duplicated from `getFloatingProps()` on purpose, and
138
- // the two cannot differ — `useRole(context, { role:
139
- // "dialog" })` above is where it comes from. Stated
140
- // here because a linter cannot see a role through a
141
- // spread, and neither can the next reader.
142
- role="dialog"
143
- ref={(node) => {
144
- refs.setFloating(node)
145
-
146
- // In a ref callback rather than an effect, because a
147
- // `[popover]` is `display: none` until it is shown —
148
- // and `FloatingFocusManager` is a *child*, so its
149
- // effect runs first and would be focusing into a
150
- // hidden element. Refs are set during commit, before
151
- // any of that.
152
- if (node && !node.matches(":popover-open")) {
153
- node.showPopover()
154
- }
155
- }}
156
- style={floatingStyles}
96
+ <FloatingPortal>
97
+ <FloatingFocusManager
98
+ context={context}
99
+ modal={false}
157
100
  >
158
- {children}
159
- </div>
160
- </FloatingFocusManager>
101
+ <div
102
+ {...getFloatingProps()}
103
+ aria-label={heading}
104
+ className={toClassName(
105
+ PANEL_SURFACE_CLASS,
106
+ "z-[var(--layer-modal)] max-w-xs p-3 text-content-primary text-sm",
107
+ className,
108
+ )}
109
+ ref={setFloating}
110
+ // Duplicated from `getFloatingProps()` on purpose, and
111
+ // the two cannot differ — `useRole(context, { role:
112
+ // "dialog" })` inside the hook is where it comes from.
113
+ // Stated here because a linter cannot see a role through
114
+ // a spread, and neither can the next reader.
115
+ role="dialog"
116
+ style={floatingStyles}
117
+ >
118
+ {children}
119
+ </div>
120
+ </FloatingFocusManager>
121
+ </FloatingPortal>
161
122
  ) : null}
162
123
  </>
163
124
  )