@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,104 @@
1
+ import type { ReactNode } from "react"
2
+ import { useEffect } from "react"
3
+
4
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import type { ListboxItem } from "./Listbox.tsx"
7
+
8
+ export type ListboxOptionProps = {
9
+ isSelected: boolean
10
+ item: ListboxItem
11
+ onSelect: (value: string) => void
12
+ /** `RovingFocus.register` — membership of the arrow-key group. */
13
+ registerFocus: (value: string) => () => void
14
+ /** `SinglePicker.register` — membership of the choice. */
15
+ registerSelection: (value: string) => () => void
16
+ tabIndex: number
17
+ trackElement: (
18
+ value: string,
19
+ element: HTMLButtonElement | null,
20
+ ) => void
21
+ }
22
+
23
+ /**
24
+ * Its own file for the same reason as `MenuAction` and `TabTrigger`:
25
+ * **both registrations are effects**, and an effect cannot run in a
26
+ * loop.
27
+ *
28
+ * The two registrations are separate on purpose, exactly as
29
+ * `TabTrigger` splits them. A disabled option joins the *choice* — it
30
+ * can still be the seeded `selectedValue` and still announces
31
+ * `aria-selected` — and stays out of the *focus* group, so the arrow
32
+ * keys and type-ahead skip it without `RovingFocus` having to know
33
+ * what "disabled" means.
34
+ *
35
+ * ### `option`, not `menuitem`
36
+ *
37
+ * An `option` **is** something — the value being chosen — where a
38
+ * `menuitem` **does** something. So this carries `aria-selected`
39
+ * (which a menuitem must not), and a screen reader announces
40
+ * "listbox, selected, 2 of 4" rather than "menu, 4 items". It is a
41
+ * `<button>` with `role="option"` so it is natively focusable for the
42
+ * roving-tabindex move, the same shape `Menu` uses.
43
+ */
44
+ export const ListboxOption = ({
45
+ isSelected,
46
+ item,
47
+ onSelect,
48
+ registerFocus,
49
+ registerSelection,
50
+ tabIndex,
51
+ trackElement,
52
+ }: ListboxOptionProps): ReactNode => {
53
+ const { isDisabled = false, label, value } = item
54
+
55
+ useEffect(
56
+ () => registerSelection(value),
57
+ [registerSelection, value],
58
+ )
59
+
60
+ useEffect(() => {
61
+ if (isDisabled) {
62
+ return
63
+ }
64
+
65
+ return registerFocus(value)
66
+ }, [isDisabled, registerFocus, value])
67
+
68
+ return (
69
+ <button
70
+ aria-selected={isSelected}
71
+ className={toClassName(
72
+ "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
73
+ "hover:bg-intent-neutral-surface",
74
+ isSelected && "bg-intent-accent-surface",
75
+ isDisabled &&
76
+ "cursor-not-allowed text-content-disabled hover:bg-transparent",
77
+ FOCUS_RING_CLASS,
78
+ )}
79
+ disabled={isDisabled}
80
+ onClick={() => {
81
+ onSelect(value)
82
+ }}
83
+ ref={(element) => {
84
+ trackElement(value, element)
85
+ }}
86
+ role="option"
87
+ tabIndex={tabIndex}
88
+ type="button"
89
+ >
90
+ {label}
91
+
92
+ {isSelected ? (
93
+ <span
94
+ // Decoration: the announced state is `aria-selected`, so the
95
+ // check must not also read out.
96
+ aria-hidden="true"
97
+ className="shrink-0 text-intent-accent-content"
98
+ >
99
+ ✓
100
+ </span>
101
+ ) : null}
102
+ </button>
103
+ )
104
+ }
package/src/Menu/Menu.mdx CHANGED
@@ -29,19 +29,17 @@ The last row is why it matters to an agent driving the app: after choosing an `o
29
29
  needs to know what the control now holds. After invoking a `menuitem` there is nothing to
30
30
  read back. Getting the role wrong is invisible in a browser and invisible to axe.
31
31
 
32
- ## A `[popover]` that was never shown is `display: none`
33
-
34
- The panel calls `showPopover()` from its ref callback, like `Popover` does — plus an
35
- `isConnected` guard and an effect that `Popover` does not need.
36
-
37
- `FloatingFocusManager` renders a pair of focus guards around its child, so React commits this
38
- subtree in an order where the ref can fire while the node is still **detached**.
39
- `showPopover()` on a disconnected element throws `InvalidStateError`, which surfaces as an
40
- unhandled rejection rather than a failing assertion — and if it is simply swallowed, the menu
41
- renders, typechecks, and is invisible to `getByRole` and to a user alike.
42
-
43
- The effect is the backstop, because effects run after the tree is connected. Both are
44
- idempotent: `:popover-open` is the same question.
32
+ ## Portalled, and no `[popover]` machinery
33
+
34
+ The panel renders in a `FloatingPortal` at `document.body`, through the same
35
+ `useAnchoredOverlay` `Popover` uses
36
+ ([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)).
37
+ A portalled node is visible when mounted, so the whole `popover="manual"` /
38
+ `showPopover()` / `isConnected`-guard class of "a `[popover]` that was never shown is
39
+ `display: none`" bug is simply gone. The menu keeps its roving focus, its arrow/Home/End
40
+ keymap, its seed-first-item-on-open effect, and its `MenuAction` items. A scoped query for
41
+ the panel now reaches for `body`; `useRole` names it by pointing `aria-labelledby` at the
42
+ trigger, so the link survives the boundary.
45
43
 
46
44
  ## There is no `label` prop, and that corrects a lesson
47
45
 
@@ -15,8 +15,7 @@ const {
15
15
  } = composeStories(stories)
16
16
 
17
17
  test("the menu is named, and its items are menuitems", async () => {
18
- const { canvas, canvasElement } =
19
- await mountStory(AllStates)
18
+ const { body } = await mountStory(AllStates)
20
19
 
21
20
  // **Named by its trigger**, not by a `label` prop. `useRole` puts
22
21
  // `aria-labelledby` on the panel pointing at the button, and
@@ -27,17 +26,17 @@ test("the menu is named, and its items are menuitems", async () => {
27
26
  // overlay with **no trigger relationship** (`Spinner`'s
28
27
  // `role="status"`, `Popover`'s `role="dialog"`) needs its own
29
28
  // name. A menu has one.
30
- expectAgentDrivable(canvas, {
29
+ const menu = expectAgentDrivable(body, {
31
30
  name: "Bay 3",
32
31
  role: "menu",
33
32
  })
34
33
 
35
- expectAgentDrivable(canvas, {
34
+ expectAgentDrivable(body, {
36
35
  name: "Retry title",
37
36
  role: "menuitem",
38
37
  })
39
38
 
40
- await expectNoAxeViolations(canvasElement)
39
+ await expectNoAxeViolations(menu)
41
40
  })
42
41
 
43
42
  /**
@@ -46,7 +45,7 @@ test("the menu is named, and its items are menuitems", async () => {
46
45
  * still on the trigger, with a menu they cannot reach.
47
46
  */
48
47
  test("opening moves focus into the menu", async () => {
49
- const { canvas } = await mountStory(Interactive)
48
+ const { body, canvas } = await mountStory(Interactive)
50
49
 
51
50
  await userEvent.click(
52
51
  expectAgentDrivable(canvas, {
@@ -57,7 +56,7 @@ test("opening moves focus into the menu", async () => {
57
56
 
58
57
  await waitFor(() => {
59
58
  expect(
60
- expectAgentDrivable(canvas, {
59
+ expectAgentDrivable(body, {
61
60
  name: "Retry title",
62
61
  role: "menuitem",
63
62
  }),
@@ -66,14 +65,14 @@ test("opening moves focus into the menu", async () => {
66
65
  })
67
66
 
68
67
  test("the arrow keys move within the menu and skip the disabled item", async () => {
69
- const { canvas } = await mountStory(AllStates)
68
+ const { body } = await mountStory(AllStates)
70
69
 
71
- const skip = expectAgentDrivable(canvas, {
70
+ const skip = expectAgentDrivable(body, {
72
71
  name: "Skip title",
73
72
  role: "menuitem",
74
73
  })
75
74
 
76
- const eject = expectAgentDrivable(canvas, {
75
+ const eject = expectAgentDrivable(body, {
77
76
  name: "Eject disc",
78
77
  role: "menuitem",
79
78
  })
@@ -84,7 +83,7 @@ test("the arrow keys move within the menu and skip the disabled item", async ()
84
83
  // clicking an item to "focus" it closes the thing under test. This
85
84
  // story opens already visible, and opening is what puts focus on
86
85
  // the first item.
87
- const retry = expectAgentDrivable(canvas, {
86
+ const retry = expectAgentDrivable(body, {
88
87
  name: "Retry title",
89
88
  role: "menuitem",
90
89
  })
@@ -118,9 +117,9 @@ test("the arrow keys move within the menu and skip the disabled item", async ()
118
117
  * that is the pattern, not a bug.
119
118
  */
120
119
  test("the menu has one tab stop", async () => {
121
- const { canvas } = await mountStory(AllStates)
120
+ const { body } = await mountStory(AllStates)
122
121
 
123
- const menu = expectAgentDrivable(canvas, {
122
+ const menu = expectAgentDrivable(body, {
124
123
  name: "Bay 3",
125
124
  role: "menu",
126
125
  })
@@ -135,7 +134,7 @@ test("the menu has one tab stop", async () => {
135
134
  })
136
135
 
137
136
  test("choosing an item dismisses the menu", async () => {
138
- const { canvas } = await mountStory(Interactive)
137
+ const { body, canvas } = await mountStory(Interactive)
139
138
 
140
139
  await userEvent.click(
141
140
  expectAgentDrivable(canvas, {
@@ -145,7 +144,7 @@ test("choosing an item dismisses the menu", async () => {
145
144
  )
146
145
 
147
146
  await userEvent.click(
148
- expectAgentDrivable(canvas, {
147
+ expectAgentDrivable(body, {
149
148
  name: "Retry title",
150
149
  role: "menuitem",
151
150
  }),
@@ -154,14 +153,12 @@ test("choosing an item dismisses the menu", async () => {
154
153
  // A menu that stays open after an action has fired is the one
155
154
  // interaction users read as "it didn't work".
156
155
  await waitFor(() => {
157
- expect(canvas.queryAllByRole("menuitem")).toHaveLength(
158
- 0,
159
- )
156
+ expect(body.queryAllByRole("menuitem")).toHaveLength(0)
160
157
  })
161
158
  })
162
159
 
163
160
  test("Escape dismisses without choosing", async () => {
164
- const { canvas } = await mountStory(Interactive)
161
+ const { body, canvas } = await mountStory(Interactive)
165
162
 
166
163
  await userEvent.click(
167
164
  expectAgentDrivable(canvas, {
@@ -172,37 +169,50 @@ test("Escape dismisses without choosing", async () => {
172
169
 
173
170
  await waitFor(() => {
174
171
  expect(
175
- canvas.queryAllByRole("menuitem").length,
172
+ body.queryAllByRole("menuitem").length,
176
173
  ).toBeGreaterThan(0)
177
174
  })
178
175
 
179
176
  await userEvent.keyboard("{Escape}")
180
177
 
181
178
  await waitFor(() => {
182
- expect(canvas.queryAllByRole("menuitem")).toHaveLength(
183
- 0,
184
- )
179
+ expect(body.queryAllByRole("menuitem")).toHaveLength(0)
185
180
  })
186
181
  })
187
182
 
188
183
  /**
189
- * The panel is in the top layer via `popover="manual"` and **stays
190
- * in the DOM where it was written** — so a scoped query still finds
191
- * it. A portalled menu would move to `document.body` and every story
192
- * here would have to reach for `screen`.
184
+ * The panel portals to `document.body` — escaping any
185
+ * `overflow: hidden` ancestor — and the trigger→panel link survives
186
+ * the boundary: `useRole` writes `aria-labelledby` on the menu
187
+ * pointing back at the button that names it. A scoped query for the
188
+ * panel now reaches for `body`; the trigger stays in the canvas.
193
189
  */
194
- test("the menu is not portalled out of its canvas", async () => {
195
- const { canvas, canvasElement } =
190
+ test("the menu portals to the body, still named by its trigger", async () => {
191
+ const { body, canvas, canvasElement } =
196
192
  await mountStory(AllVariants)
197
193
 
198
- const menu = expectAgentDrivable(canvas, {
194
+ const trigger = expectAgentDrivable(canvas, {
195
+ name: "Bay 2",
196
+ role: "button",
197
+ })
198
+
199
+ const menu = expectAgentDrivable(body, {
199
200
  name: "Bay 2",
200
201
  role: "menu",
201
202
  })
202
203
 
203
- await expect(canvasElement).toContainElement(menu)
204
+ await expect(canvasElement).not.toContainElement(menu)
205
+
206
+ await expect(document.body).toContainElement(menu)
207
+
208
+ // The link, across the portal: the menu is named by pointing at its
209
+ // trigger, and the id really resolves.
210
+ await expect(menu).toHaveAttribute(
211
+ "aria-labelledby",
212
+ trigger.id,
213
+ )
204
214
 
205
- await expectNoAxeViolations(canvasElement)
215
+ await expectNoAxeViolations(menu)
206
216
  })
207
217
 
208
218
  /**
@@ -239,8 +249,7 @@ const expectAnchoredTrigger = (trigger: HTMLElement) => {
239
249
  }
240
250
 
241
251
  test("a menu sharing its trigger with a tooltip is still anchored to it", async () => {
242
- const { canvas, canvasElement } =
243
- await mountStory(SharedTrigger)
252
+ const { body, canvas } = await mountStory(SharedTrigger)
244
253
 
245
254
  const trigger = expectAgentDrivable(canvas, {
246
255
  name: "Bay 5",
@@ -252,7 +261,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
252
261
  await userEvent.click(trigger)
253
262
 
254
263
  const menu = await waitFor(() =>
255
- expectAgentDrivable(canvas, { role: "menu" }),
264
+ expectAgentDrivable(body, { role: "menu" }),
256
265
  )
257
266
 
258
267
  // `bottom-start` with `offset(4)`: flush with the trigger's
@@ -273,7 +282,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
273
282
  )
274
283
  })
275
284
 
276
- await expectNoAxeViolations(canvasElement)
285
+ await expectNoAxeViolations(menu)
277
286
  })
278
287
 
279
288
  /**
@@ -282,7 +291,7 @@ test("a menu sharing its trigger with a tooltip is still anchored to it", async
282
291
  * pointing at nothing and the test above would still pass.
283
292
  */
284
293
  test("the tooltip sharing that trigger is anchored to it too", async () => {
285
- const { canvas } = await mountStory(SharedTrigger)
294
+ const { body, canvas } = await mountStory(SharedTrigger)
286
295
 
287
296
  const trigger = expectAgentDrivable(canvas, {
288
297
  name: "Bay 5",
@@ -294,7 +303,7 @@ test("the tooltip sharing that trigger is anchored to it too", async () => {
294
303
  trigger.focus()
295
304
 
296
305
  const tip = await waitFor(() =>
297
- expectAgentDrivable(canvas, { role: "tooltip" }),
306
+ expectAgentDrivable(body, { role: "tooltip" }),
298
307
  )
299
308
 
300
309
  // The **main axis** only: `top` with `offset(6)`. The cross axis
package/src/Menu/Menu.tsx CHANGED
@@ -1,23 +1,17 @@
1
1
  import {
2
2
  selectTabIndex,
3
- useClonedChild,
4
3
  useRovingFocus,
5
4
  } from "@charcuterie/logic"
6
5
  import type { Placement } from "@floating-ui/react"
7
6
  import {
8
- autoUpdate,
9
7
  FloatingFocusManager,
10
- flip,
11
- offset,
12
- shift,
13
- useDismiss,
14
- useFloating,
15
- useInteractions,
16
- useRole,
8
+ FloatingPortal,
17
9
  } from "@floating-ui/react"
18
10
  import type { ReactElement, ReactNode } from "react"
19
11
  import { useEffect, useRef } from "react"
20
12
 
13
+ import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
14
+ import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
21
15
  import { toClassName } from "../toClassName.ts"
22
16
  import { MenuAction } from "./MenuAction.tsx"
23
17
 
@@ -112,8 +106,6 @@ export const Menu = ({
112
106
  new Map<string, HTMLButtonElement>(),
113
107
  )
114
108
 
115
- const panelElement = useRef<HTMLDivElement>(null)
116
-
117
109
  const focus = useRovingFocus<string>()
118
110
 
119
111
  const { activeValue, registeredValues, setActiveValue } =
@@ -121,53 +113,21 @@ export const Menu = ({
121
113
 
122
114
  const [firstValue] = registeredValues
123
115
 
124
- const { context, floatingStyles, refs } = useFloating({
125
- open: isVisible,
126
- onOpenChange: (isNextVisible) => {
127
- if (!isNextVisible) {
128
- onDismiss()
129
- }
130
- },
116
+ const {
117
+ clonedTrigger,
118
+ context,
119
+ floatingStyles,
120
+ getFloatingProps,
121
+ setFloating,
122
+ } = useAnchoredOverlay({
123
+ isVisible,
124
+ offsetValue: 4,
125
+ onDismiss,
131
126
  placement,
132
- middleware: [offset(4), flip(), shift({ padding: 8 })],
133
- strategy: "fixed",
134
- whileElementsMounted: autoUpdate,
135
- })
136
-
137
- const { getFloatingProps, getReferenceProps } =
138
- useInteractions([
139
- useDismiss(context),
140
- useRole(context, { role: "menu" }),
141
- ])
142
-
143
- const clonedTrigger = useClonedChild(trigger, {
144
- ...getReferenceProps(),
145
- ref: refs.setReference,
127
+ role: "menu",
128
+ trigger,
146
129
  })
147
130
 
148
- /**
149
- * The other half of the `isConnected` guard below. If the ref
150
- * fired while the node was still detached, nothing has shown the
151
- * popover — and a `[popover]` that was never shown is
152
- * `display: none`, so the menu renders, passes typecheck, and is
153
- * invisible to `getByRole` and to a user alike.
154
- *
155
- * Effects run after the tree is connected, so this is the
156
- * backstop. It is idempotent: `:popover-open` is the same question
157
- * the ref asks.
158
- */
159
- useEffect(() => {
160
- const panel = panelElement.current
161
-
162
- if (
163
- isVisible &&
164
- panel?.isConnected &&
165
- !panel.matches(":popover-open")
166
- ) {
167
- panel.showPopover()
168
- }
169
- }, [isVisible])
170
-
171
131
  /**
172
132
  * Opening **makes** a member active, and this is the step the
173
133
  * first version was missing.
@@ -205,95 +165,74 @@ export const Menu = ({
205
165
  {clonedTrigger}
206
166
 
207
167
  {isVisible ? (
208
- <FloatingFocusManager
209
- context={context}
210
- // `false`, so Tab leaves the menu and lands on the next
211
- // control in the page — which is what closes it, through
212
- // `useDismiss`. A trapped menu is a dialog.
213
- modal={false}
214
- // Focus is moved by the effect above, to whichever item
215
- // `RovingFocus` made active. Letting the manager also aim
216
- // it is two owners for one caret.
217
- initialFocus={-1}
218
- >
219
- <div
220
- {...getFloatingProps()}
221
- className={toClassName(
222
- "inset-auto m-0 flex min-w-48 flex-col gap-0.5 rounded-md border border-border-default bg-surface-overlay p-1 shadow-medium",
223
- className,
224
- )}
225
- onKeyDown={(keyEvent) => {
226
- const commands: Record<string, () => void> = {
227
- ArrowDown: focus.next,
228
- ArrowUp: focus.previous,
229
- End: focus.last,
230
- Home: focus.first,
231
- }
232
-
233
- const command = commands[keyEvent.key]
234
-
235
- if (command) {
236
- // Arrow keys scroll the page by default, and a menu
237
- // that scrolls the document behind it while moving
238
- // focus is the hand-rolled version of this
239
- // component.
240
- keyEvent.preventDefault()
241
-
242
- command()
243
- }
244
- }}
245
- popover="manual"
246
- ref={(node) => {
247
- refs.setFloating(node)
248
-
249
- panelElement.current = node
250
-
251
- // A ref callback rather than an effect, for the same
252
- // reason as `Popover`: a `[popover]` is
253
- // `display: none` until shown, and every child effect
254
- // — including the focus move above — would otherwise
255
- // be aiming at a hidden element.
256
- //
257
- // `isConnected` is the guard `Popover` does not need
258
- // and this does. `FloatingFocusManager` renders a pair
259
- // of focus guards around its child, so React commits
260
- // this subtree in an order where the ref can fire
261
- // while the node is still detached — and
262
- // `showPopover()` on a disconnected element throws
263
- // `InvalidStateError`, which surfaces as an unhandled
264
- // rejection rather than as a failing assertion.
265
- if (
266
- node?.isConnected &&
267
- !node.matches(":popover-open")
268
- ) {
269
- node.showPopover()
270
- }
271
- }}
272
- // Duplicated from `getFloatingProps()` on purpose, and
273
- // the two cannot differ — `useRole(context, { role:
274
- // "menu" })` above is where it comes from. Stated here
275
- // because a linter cannot see a role through a spread.
276
- role="menu"
277
- style={floatingStyles}
168
+ <FloatingPortal>
169
+ <FloatingFocusManager
170
+ context={context}
171
+ // `false`, so Tab leaves the menu and lands on the next
172
+ // control in the page — which is what closes it, through
173
+ // `useDismiss`. A trapped menu is a dialog.
174
+ modal={false}
175
+ // Focus is moved by the effect above, to whichever item
176
+ // `RovingFocus` made active. Letting the manager also aim
177
+ // it is two owners for one caret.
178
+ initialFocus={-1}
278
179
  >
279
- {items.map((item) => (
280
- <MenuAction
281
- item={item}
282
- key={item.key}
283
- onDismiss={onDismiss}
284
- register={focus.register}
285
- tabIndex={selectTabIndex(focus, item.key)}
286
- trackElement={(key, element) => {
287
- if (element) {
288
- itemElements.current.set(key, element)
289
- } else {
290
- itemElements.current.delete(key)
180
+ <div
181
+ {...getFloatingProps()}
182
+ className={toClassName(
183
+ PANEL_SURFACE_CLASS,
184
+ "z-[var(--layer-modal)] flex min-w-48 flex-col gap-0.5 p-1",
185
+ className,
186
+ )}
187
+ onKeyDown={(keyEvent) => {
188
+ const commands: Record<string, () => void> =
189
+ {
190
+ ArrowDown: focus.next,
191
+ ArrowUp: focus.previous,
192
+ End: focus.last,
193
+ Home: focus.first,
291
194
  }
292
- }}
293
- />
294
- ))}
295
- </div>
296
- </FloatingFocusManager>
195
+
196
+ const command = commands[keyEvent.key]
197
+
198
+ if (command) {
199
+ // Arrow keys scroll the page by default, and a menu
200
+ // that scrolls the document behind it while moving
201
+ // focus is the hand-rolled version of this
202
+ // component.
203
+ keyEvent.preventDefault()
204
+
205
+ command()
206
+ }
207
+ }}
208
+ ref={setFloating}
209
+ // Duplicated from `getFloatingProps()` on purpose, and
210
+ // the two cannot differ — `useRole(context, { role:
211
+ // "menu" })` in the hook is where it comes from. Stated
212
+ // here because a linter cannot see a role through a
213
+ // spread.
214
+ role="menu"
215
+ style={floatingStyles}
216
+ >
217
+ {items.map((item) => (
218
+ <MenuAction
219
+ item={item}
220
+ key={item.key}
221
+ onDismiss={onDismiss}
222
+ register={focus.register}
223
+ tabIndex={selectTabIndex(focus, item.key)}
224
+ trackElement={(key, element) => {
225
+ if (element) {
226
+ itemElements.current.set(key, element)
227
+ } else {
228
+ itemElements.current.delete(key)
229
+ }
230
+ }}
231
+ />
232
+ ))}
233
+ </div>
234
+ </FloatingFocusManager>
235
+ </FloatingPortal>
297
236
  ) : null}
298
237
  </>
299
238
  )