@charcuterie/ui 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/Combobox/Combobox.d.ts +54 -0
  2. package/dist/Combobox/Combobox.d.ts.map +1 -0
  3. package/dist/Combobox/Combobox.js +216 -0
  4. package/dist/Combobox/Combobox.js.map +1 -0
  5. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  7. package/dist/Combobox/ComboboxOption.js +32 -0
  8. package/dist/Combobox/ComboboxOption.js.map +1 -0
  9. package/dist/Dialog/Dialog.d.ts +47 -0
  10. package/dist/Dialog/Dialog.d.ts.map +1 -0
  11. package/dist/Dialog/Dialog.js +52 -0
  12. package/dist/Dialog/Dialog.js.map +1 -0
  13. package/dist/Lightbox/Lightbox.d.ts +7 -7
  14. package/dist/Lightbox/Lightbox.js +9 -9
  15. package/dist/Lightbox/Lightbox.js.map +1 -1
  16. package/dist/Listbox/Listbox.d.ts +56 -0
  17. package/dist/Listbox/Listbox.d.ts.map +1 -0
  18. package/dist/Listbox/Listbox.js +151 -0
  19. package/dist/Listbox/Listbox.js.map +1 -0
  20. package/dist/Listbox/ListboxOption.d.ts +36 -0
  21. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  22. package/dist/Listbox/ListboxOption.js +42 -0
  23. package/dist/Listbox/ListboxOption.js.map +1 -0
  24. package/dist/Menu/Menu.d.ts.map +1 -1
  25. package/dist/Menu/Menu.js +47 -97
  26. package/dist/Menu/Menu.js.map +1 -1
  27. package/dist/Modal/Modal.d.ts +11 -59
  28. package/dist/Modal/Modal.d.ts.map +1 -1
  29. package/dist/Modal/Modal.js +28 -101
  30. package/dist/Modal/Modal.js.map +1 -1
  31. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  32. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  33. package/dist/Overlay/OverlayPanel.js +72 -0
  34. package/dist/Overlay/OverlayPanel.js.map +1 -0
  35. package/dist/Overlay/OverlayStack.d.ts +47 -0
  36. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  37. package/dist/Overlay/OverlayStack.js +46 -0
  38. package/dist/Overlay/OverlayStack.js.map +1 -0
  39. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  40. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  41. package/dist/Overlay/SharedBackdrop.js +28 -0
  42. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  43. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  44. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  45. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  46. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  47. package/dist/Overlay/overlayPanelClass.js +17 -0
  48. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  49. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  50. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  51. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  52. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  53. package/dist/Popover/Popover.d.ts +16 -24
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +32 -68
  56. package/dist/Popover/Popover.js.map +1 -1
  57. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  58. package/dist/Tooltip/Tooltip.js +25 -25
  59. package/dist/Tooltip/Tooltip.js.map +1 -1
  60. package/dist/index.d.ts +7 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/mountStory.testHelpers.d.ts +1 -0
  65. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  66. package/dist/mountStory.testHelpers.js +21 -1
  67. package/dist/mountStory.testHelpers.js.map +1 -1
  68. package/package.json +3 -2
  69. package/src/Combobox/Combobox.mdx +71 -0
  70. package/src/Combobox/Combobox.stories.tsx +245 -0
  71. package/src/Combobox/Combobox.test.tsx +289 -0
  72. package/src/Combobox/Combobox.tsx +513 -0
  73. package/src/Combobox/ComboboxOption.tsx +88 -0
  74. package/src/Dialog/Dialog.mdx +87 -0
  75. package/src/Dialog/Dialog.stories.tsx +347 -0
  76. package/src/Dialog/Dialog.test.tsx +312 -0
  77. package/src/Dialog/Dialog.tsx +127 -0
  78. package/src/Field/Field.test.tsx +6 -4
  79. package/src/Lightbox/Lightbox.mdx +9 -8
  80. package/src/Lightbox/Lightbox.stories.tsx +5 -5
  81. package/src/Lightbox/Lightbox.test.tsx +25 -36
  82. package/src/Lightbox/Lightbox.tsx +10 -10
  83. package/src/Listbox/Listbox.mdx +68 -0
  84. package/src/Listbox/Listbox.stories.tsx +205 -0
  85. package/src/Listbox/Listbox.test.tsx +198 -0
  86. package/src/Listbox/Listbox.tsx +295 -0
  87. package/src/Listbox/ListboxOption.tsx +104 -0
  88. package/src/Menu/Menu.mdx +11 -13
  89. package/src/Menu/Menu.test.tsx +47 -38
  90. package/src/Menu/Menu.tsx +81 -142
  91. package/src/Modal/Modal.mdx +29 -82
  92. package/src/Modal/Modal.stories.tsx +55 -188
  93. package/src/Modal/Modal.test.tsx +44 -177
  94. package/src/Modal/Modal.tsx +54 -204
  95. package/src/Overlay/OverlayPanel.tsx +195 -0
  96. package/src/Overlay/OverlayStack.test.tsx +235 -0
  97. package/src/Overlay/OverlayStack.tsx +132 -0
  98. package/src/Overlay/SharedBackdrop.tsx +40 -0
  99. package/src/Overlay/overlayPanelClass.ts +17 -0
  100. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  101. package/src/Popover/Popover.mdx +17 -20
  102. package/src/Popover/Popover.stories.tsx +33 -0
  103. package/src/Popover/Popover.test.tsx +68 -32
  104. package/src/Popover/Popover.tsx +56 -95
  105. package/src/Tooltip/Tooltip.mdx +12 -9
  106. package/src/Tooltip/Tooltip.test.tsx +17 -19
  107. package/src/Tooltip/Tooltip.tsx +35 -32
  108. package/src/index.ts +13 -4
  109. package/src/mountStory.testHelpers.ts +25 -1
  110. package/src/sourceRules.test.ts +12 -3
  111. package/src/storyControls.test.ts +25 -12
  112. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  113. package/dist/Modal/lockScrollBehind.js.map +0 -1
  114. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  115. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  116. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -0,0 +1,198 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Listbox.stories.tsx"
9
+
10
+ const { AllStates, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ test("a button opens a listbox whose rows are options", async () => {
14
+ const { body, canvas } = await mountStory(Default)
15
+
16
+ const trigger = expectAgentDrivable(canvas, {
17
+ name: "Choose a language",
18
+ role: "button",
19
+ })
20
+
21
+ // The trigger stays a native button that *opens* a listbox — it is
22
+ // not a `combobox`. It gains `aria-haspopup="listbox"`.
23
+ await expect(trigger).toHaveAttribute(
24
+ "aria-haspopup",
25
+ "listbox",
26
+ )
27
+
28
+ await userEvent.click(trigger)
29
+
30
+ const listbox = expectAgentDrivable(body, {
31
+ role: "listbox",
32
+ })
33
+
34
+ // `option`, not `menuitem` — a value being chosen, so it carries
35
+ // `aria-selected`.
36
+ const english = expectAgentDrivable(body, {
37
+ name: "English",
38
+ role: "option",
39
+ })
40
+
41
+ await expect(english).toHaveAttribute(
42
+ "aria-selected",
43
+ "false",
44
+ )
45
+
46
+ await expectNoAxeViolations(listbox)
47
+ })
48
+
49
+ test("opening focuses the seeded selection, which reads selected", async () => {
50
+ const { body, canvas } = await mountStory(AllStates)
51
+
52
+ // The trigger reflects the seeded value.
53
+ await userEvent.click(
54
+ expectAgentDrivable(canvas, {
55
+ name: "Spanish",
56
+ role: "button",
57
+ }),
58
+ )
59
+
60
+ const spanish = expectAgentDrivable(body, {
61
+ name: "Spanish",
62
+ role: "option",
63
+ })
64
+
65
+ await expect(spanish).toHaveAttribute(
66
+ "aria-selected",
67
+ "true",
68
+ )
69
+
70
+ // The APG rule: a listbox opens on its current value.
71
+ await waitFor(() => {
72
+ expect(spanish).toHaveFocus()
73
+ })
74
+ })
75
+
76
+ test("the arrow keys move focus and skip the disabled option", async () => {
77
+ const { body, canvas } = await mountStory(Default)
78
+
79
+ await userEvent.click(
80
+ expectAgentDrivable(canvas, {
81
+ name: "Choose a language",
82
+ role: "button",
83
+ }),
84
+ )
85
+
86
+ const english = expectAgentDrivable(body, {
87
+ name: "English",
88
+ role: "option",
89
+ })
90
+
91
+ const japanese = expectAgentDrivable(body, {
92
+ name: "Japanese",
93
+ role: "option",
94
+ })
95
+
96
+ await expect(
97
+ expectAgentDrivable(body, {
98
+ name: "German",
99
+ role: "option",
100
+ }),
101
+ ).toBeDisabled()
102
+
103
+ await waitFor(() => {
104
+ expect(english).toHaveFocus()
105
+ })
106
+
107
+ // English → Spanish → French → Japanese, skipping the disabled
108
+ // German, which never joined the focus group.
109
+ await userEvent.keyboard(
110
+ "{ArrowDown}{ArrowDown}{ArrowDown}",
111
+ )
112
+
113
+ await waitFor(() => {
114
+ expect(japanese).toHaveFocus()
115
+ })
116
+ })
117
+
118
+ test("type-ahead jumps to the first match", async () => {
119
+ const { body, canvas } = await mountStory(Default)
120
+
121
+ await userEvent.click(
122
+ expectAgentDrivable(canvas, {
123
+ name: "Choose a language",
124
+ role: "button",
125
+ }),
126
+ )
127
+
128
+ const japanese = expectAgentDrivable(body, {
129
+ name: "Japanese",
130
+ role: "option",
131
+ })
132
+
133
+ await userEvent.keyboard("j")
134
+
135
+ await waitFor(() => {
136
+ expect(japanese).toHaveFocus()
137
+ })
138
+ })
139
+
140
+ test("Enter selects the focused option and closes", async () => {
141
+ const { body, canvas } = await mountStory(Interactive)
142
+
143
+ await userEvent.click(
144
+ expectAgentDrivable(canvas, {
145
+ name: "Choose a language",
146
+ role: "button",
147
+ }),
148
+ )
149
+
150
+ // English is first and focused; arrow to Spanish and choose it.
151
+ await userEvent.keyboard("{ArrowDown}{Enter}")
152
+
153
+ await waitFor(() => {
154
+ expect(body.queryByRole("listbox")).toBeNull()
155
+ })
156
+
157
+ // Choosing flows back through `onSelect`, so the trigger now reads
158
+ // the chosen value.
159
+ expectAgentDrivable(canvas, {
160
+ name: "Spanish",
161
+ role: "button",
162
+ })
163
+ })
164
+
165
+ test("Escape dismisses without choosing, and there is one tab stop", async () => {
166
+ const { body, canvas } = await mountStory(Default)
167
+
168
+ await userEvent.click(
169
+ expectAgentDrivable(canvas, {
170
+ name: "Choose a language",
171
+ role: "button",
172
+ }),
173
+ )
174
+
175
+ const listbox = expectAgentDrivable(body, {
176
+ role: "listbox",
177
+ })
178
+
179
+ const tabbable = Array.from(
180
+ listbox.querySelectorAll<HTMLButtonElement>(
181
+ '[role="option"]',
182
+ ),
183
+ ).filter((one) => one.tabIndex === 0)
184
+
185
+ await expect(tabbable).toHaveLength(1)
186
+
187
+ await userEvent.keyboard("{Escape}")
188
+
189
+ await waitFor(() => {
190
+ expect(body.queryByRole("listbox")).toBeNull()
191
+ })
192
+
193
+ // Unchanged trigger — nothing was chosen.
194
+ expectAgentDrivable(canvas, {
195
+ name: "Choose a language",
196
+ role: "button",
197
+ })
198
+ })
@@ -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