@charcuterie/ui 1.1.0 → 2.0.1

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 +53 -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 +397 -0
  76. package/src/Dialog/Dialog.test.tsx +340 -0
  77. package/src/Dialog/Dialog.tsx +133 -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,295 @@
1
+ import {
2
+ selectTabIndex,
3
+ useRovingFocus,
4
+ useSinglePicker,
5
+ } from "@charcuterie/logic"
6
+ import type { Placement } from "@floating-ui/react"
7
+ import {
8
+ FloatingFocusManager,
9
+ FloatingPortal,
10
+ } from "@floating-ui/react"
11
+ import type { ReactElement, ReactNode } from "react"
12
+ import { useEffect, useRef } from "react"
13
+
14
+ import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
15
+ import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
16
+ import { toClassName } from "../toClassName.ts"
17
+ import { ListboxOption } from "./ListboxOption.tsx"
18
+
19
+ export type ListboxItem = {
20
+ isDisabled?: boolean
21
+ label: ReactNode
22
+ /**
23
+ * Required when `label` is rich (an icon, two lines, a badge): it is
24
+ * the type-ahead target and the accessible name a screen reader
25
+ * reads. When `label` is a plain string it is used as the fallback.
26
+ */
27
+ textValue?: string
28
+ value: string
29
+ }
30
+
31
+ export type ListboxProps = {
32
+ className?: string
33
+ isVisible: boolean
34
+ /** Outside press and Escape both land here. */
35
+ onDismiss: () => void
36
+ onSelect: (value: string) => void
37
+ options: readonly ListboxItem[]
38
+ placement?: Placement
39
+ /** The initial seed. The live selection is the listbox's after that. */
40
+ selectedValue?: string
41
+ /** The control the listbox hangs off. **Cloned, not wrapped.** */
42
+ trigger: ReactElement
43
+ }
44
+
45
+ const getOptionText = (item: ListboxItem) =>
46
+ (
47
+ item.textValue ??
48
+ (typeof item.label === "string" ? item.label : "")
49
+ ).toLowerCase()
50
+
51
+ /**
52
+ * The single-select fancy dropdown — the rich-option sibling of the
53
+ * native `Select`. `Select` keeps type-ahead, the mobile wheel, form
54
+ * submission and autofill; `Listbox` is for the cases `<option>`
55
+ * cannot render: an icon, two lines, a trailing badge.
56
+ *
57
+ * A listbox is **not** a menu, and that distinction is the whole
58
+ * reason it is a separate component from `Menu`. An `option` *is*
59
+ * something you are choosing — so it carries `aria-selected`, it
60
+ * type-aheads (which the APG requires of a listbox and `Menu`
61
+ * deliberately omits), and a screen reader announces "listbox,
62
+ * selected, 2 of 4". A `menuitem` only ever *does* something.
63
+ *
64
+ * ### Two kinds of state, composed
65
+ *
66
+ * `useRovingFocus` owns which option is tabbable (exactly one, so Tab
67
+ * enters and leaves the group once and the arrow keys move within it),
68
+ * and `useSinglePicker` owns which option is chosen. They are the same
69
+ * two kinds `Tabs` composes, and they never disturb each other:
70
+ * arrowing moves focus without selecting, Enter selects the focused
71
+ * option.
72
+ *
73
+ * ### Portalled, and named by its trigger
74
+ *
75
+ * Like `Menu`, the panel portals to `document.body` and `useRole` names
76
+ * it by pointing `aria-labelledby` at the trigger — so there is no
77
+ * `heading` prop; the button that opens it is its label.
78
+ */
79
+ export const Listbox = ({
80
+ className,
81
+ isVisible,
82
+ onDismiss,
83
+ onSelect,
84
+ options,
85
+ placement = "bottom-start",
86
+ selectedValue,
87
+ trigger,
88
+ }: ListboxProps): ReactNode => {
89
+ const optionElements = useRef(
90
+ new Map<string, HTMLButtonElement>(),
91
+ )
92
+
93
+ const typeahead = useRef({ buffer: "", stampMs: 0 })
94
+
95
+ const focus = useRovingFocus<string>()
96
+
97
+ const picker = useSinglePicker<string>({ selectedValue })
98
+
99
+ const { activeValue, registeredValues, setActiveValue } =
100
+ focus
101
+
102
+ const [firstValue] = registeredValues
103
+
104
+ const {
105
+ clonedTrigger,
106
+ context,
107
+ floatingStyles,
108
+ getFloatingProps,
109
+ setFloating,
110
+ triggerId,
111
+ } = useAnchoredOverlay({
112
+ isVisible,
113
+ maxHeightPx: 384,
114
+ offsetValue: 4,
115
+ onDismiss,
116
+ placement,
117
+ role: "listbox",
118
+ trigger,
119
+ })
120
+
121
+ const chooseValue = (value: string) => {
122
+ picker.select(value)
123
+
124
+ onSelect(value)
125
+
126
+ // A single choice closes, the way the native `<select>` it stands
127
+ // in for does — the consumer's `onDismiss` is the same one an
128
+ // outside press and Escape use.
129
+ onDismiss()
130
+ }
131
+
132
+ /**
133
+ * Opening lands the focus on the chosen option, or the first when
134
+ * nothing is chosen yet — the APG rule for a listbox, and the reason
135
+ * a reopened one resumes on the current value rather than the top.
136
+ */
137
+ useEffect(() => {
138
+ if (!isVisible) {
139
+ return
140
+ }
141
+
142
+ const seed = picker.selectedValue ?? firstValue
143
+
144
+ if (seed !== undefined && seed !== null) {
145
+ setActiveValue(seed)
146
+ }
147
+ }, [
148
+ firstValue,
149
+ isVisible,
150
+ picker.selectedValue,
151
+ setActiveValue,
152
+ ])
153
+
154
+ useEffect(() => {
155
+ if (!isVisible || activeValue === null) {
156
+ return
157
+ }
158
+
159
+ optionElements.current.get(activeValue)?.focus()
160
+ }, [activeValue, isVisible])
161
+
162
+ const runTypeahead = (character: string) => {
163
+ // Reset the buffer once the typing pauses, so "sk" then a beat
164
+ // then "e" is two searches rather than "ske".
165
+ const now = Date.now()
166
+
167
+ typeahead.current.buffer =
168
+ now - typeahead.current.stampMs > 500
169
+ ? character
170
+ : typeahead.current.buffer + character
171
+
172
+ typeahead.current.stampMs = now
173
+
174
+ const query = typeahead.current.buffer.toLowerCase()
175
+
176
+ const match = options.find(
177
+ (option) =>
178
+ !option.isDisabled &&
179
+ getOptionText(option).startsWith(query),
180
+ )
181
+
182
+ if (match) {
183
+ setActiveValue(match.value)
184
+ }
185
+ }
186
+
187
+ return (
188
+ <>
189
+ {clonedTrigger}
190
+
191
+ {isVisible ? (
192
+ <FloatingPortal>
193
+ <FloatingFocusManager
194
+ context={context}
195
+ initialFocus={-1}
196
+ modal={false}
197
+ >
198
+ <div
199
+ {...getFloatingProps()}
200
+ // A bare `role="listbox"` is an ARIA input field and must
201
+ // be named; pointing at the trigger across the portal is
202
+ // the same link `useRole` makes for a `Menu`.
203
+ aria-labelledby={triggerId}
204
+ className={toClassName(
205
+ PANEL_SURFACE_CLASS,
206
+ // The height is clamped to the viewport by the `size`
207
+ // middleware (`maxHeightPx`), written as an inline
208
+ // style; this scrolls within it.
209
+ "z-[var(--layer-modal)] flex min-w-48 flex-col gap-0.5 overflow-auto p-1",
210
+ className,
211
+ )}
212
+ onKeyDown={(keyEvent) => {
213
+ const commands: Record<string, () => void> =
214
+ {
215
+ ArrowDown: focus.next,
216
+ ArrowUp: focus.previous,
217
+ End: focus.last,
218
+ Home: focus.first,
219
+ }
220
+
221
+ const command = commands[keyEvent.key]
222
+
223
+ if (command) {
224
+ keyEvent.preventDefault()
225
+
226
+ command()
227
+
228
+ return
229
+ }
230
+
231
+ if (
232
+ (keyEvent.key === "Enter" ||
233
+ keyEvent.key === " ") &&
234
+ activeValue !== null
235
+ ) {
236
+ keyEvent.preventDefault()
237
+
238
+ chooseValue(activeValue)
239
+
240
+ return
241
+ }
242
+
243
+ // Type-ahead: a single printable character with no
244
+ // modifier is a search, not a shortcut.
245
+ if (
246
+ keyEvent.key.length === 1 &&
247
+ !keyEvent.altKey &&
248
+ !keyEvent.ctrlKey &&
249
+ !keyEvent.metaKey
250
+ ) {
251
+ runTypeahead(keyEvent.key)
252
+ }
253
+ }}
254
+ ref={setFloating}
255
+ // Duplicated from `getFloatingProps()` on purpose, and
256
+ // the two cannot differ — `useRole(context, { role:
257
+ // "listbox" })` in the hook is where it comes from.
258
+ // Stated here because a linter cannot see a role through
259
+ // a spread.
260
+ role="listbox"
261
+ style={floatingStyles}
262
+ >
263
+ {options.map((option) => (
264
+ <ListboxOption
265
+ isSelected={
266
+ picker.selectedValue === option.value
267
+ }
268
+ item={option}
269
+ key={option.value}
270
+ onSelect={chooseValue}
271
+ registerFocus={focus.register}
272
+ registerSelection={picker.register}
273
+ tabIndex={selectTabIndex(
274
+ focus,
275
+ option.value,
276
+ )}
277
+ trackElement={(value, element) => {
278
+ if (element) {
279
+ optionElements.current.set(
280
+ value,
281
+ element,
282
+ )
283
+ } else {
284
+ optionElements.current.delete(value)
285
+ }
286
+ }}
287
+ />
288
+ ))}
289
+ </div>
290
+ </FloatingFocusManager>
291
+ </FloatingPortal>
292
+ ) : null}
293
+ </>
294
+ )
295
+ }
@@ -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