@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.
- package/dist/Combobox/Combobox.d.ts +54 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -0
- package/dist/Combobox/Combobox.js +216 -0
- package/dist/Combobox/Combobox.js.map +1 -0
- package/dist/Combobox/ComboboxOption.d.ts +36 -0
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
- package/dist/Combobox/ComboboxOption.js +32 -0
- package/dist/Combobox/ComboboxOption.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +53 -0
- package/dist/Dialog/Dialog.d.ts.map +1 -0
- package/dist/Dialog/Dialog.js +52 -0
- package/dist/Dialog/Dialog.js.map +1 -0
- package/dist/Lightbox/Lightbox.d.ts +7 -7
- package/dist/Lightbox/Lightbox.js +9 -9
- package/dist/Lightbox/Lightbox.js.map +1 -1
- package/dist/Listbox/Listbox.d.ts +56 -0
- package/dist/Listbox/Listbox.d.ts.map +1 -0
- package/dist/Listbox/Listbox.js +151 -0
- package/dist/Listbox/Listbox.js.map +1 -0
- package/dist/Listbox/ListboxOption.d.ts +36 -0
- package/dist/Listbox/ListboxOption.d.ts.map +1 -0
- package/dist/Listbox/ListboxOption.js +42 -0
- package/dist/Listbox/ListboxOption.js.map +1 -0
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +47 -97
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Modal/Modal.d.ts +11 -59
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +28 -101
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +50 -0
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
- package/dist/Overlay/OverlayPanel.js +72 -0
- package/dist/Overlay/OverlayPanel.js.map +1 -0
- package/dist/Overlay/OverlayStack.d.ts +47 -0
- package/dist/Overlay/OverlayStack.d.ts.map +1 -0
- package/dist/Overlay/OverlayStack.js +46 -0
- package/dist/Overlay/OverlayStack.js.map +1 -0
- package/dist/Overlay/SharedBackdrop.d.ts +24 -0
- package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
- package/dist/Overlay/SharedBackdrop.js +28 -0
- package/dist/Overlay/SharedBackdrop.js.map +1 -0
- package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
- package/dist/Overlay/lockScrollBehind.js.map +1 -0
- package/dist/Overlay/overlayPanelClass.d.ts +17 -0
- package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
- package/dist/Overlay/overlayPanelClass.js +17 -0
- package/dist/Overlay/overlayPanelClass.js.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
- package/dist/Overlay/useAnchoredOverlay.js +81 -0
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
- package/dist/Popover/Popover.d.ts +16 -24
- package/dist/Popover/Popover.d.ts.map +1 -1
- package/dist/Popover/Popover.js +32 -68
- package/dist/Popover/Popover.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +25 -25
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +1 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +21 -1
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +3 -2
- package/src/Combobox/Combobox.mdx +71 -0
- package/src/Combobox/Combobox.stories.tsx +245 -0
- package/src/Combobox/Combobox.test.tsx +289 -0
- package/src/Combobox/Combobox.tsx +513 -0
- package/src/Combobox/ComboboxOption.tsx +88 -0
- package/src/Dialog/Dialog.mdx +87 -0
- package/src/Dialog/Dialog.stories.tsx +397 -0
- package/src/Dialog/Dialog.test.tsx +340 -0
- package/src/Dialog/Dialog.tsx +133 -0
- package/src/Field/Field.test.tsx +6 -4
- package/src/Lightbox/Lightbox.mdx +9 -8
- package/src/Lightbox/Lightbox.stories.tsx +5 -5
- package/src/Lightbox/Lightbox.test.tsx +25 -36
- package/src/Lightbox/Lightbox.tsx +10 -10
- package/src/Listbox/Listbox.mdx +68 -0
- package/src/Listbox/Listbox.stories.tsx +205 -0
- package/src/Listbox/Listbox.test.tsx +198 -0
- package/src/Listbox/Listbox.tsx +295 -0
- package/src/Listbox/ListboxOption.tsx +104 -0
- package/src/Menu/Menu.mdx +11 -13
- package/src/Menu/Menu.test.tsx +47 -38
- package/src/Menu/Menu.tsx +81 -142
- package/src/Modal/Modal.mdx +29 -82
- package/src/Modal/Modal.stories.tsx +55 -188
- package/src/Modal/Modal.test.tsx +44 -177
- package/src/Modal/Modal.tsx +54 -204
- package/src/Overlay/OverlayPanel.tsx +195 -0
- package/src/Overlay/OverlayStack.test.tsx +235 -0
- package/src/Overlay/OverlayStack.tsx +132 -0
- package/src/Overlay/SharedBackdrop.tsx +40 -0
- package/src/Overlay/overlayPanelClass.ts +17 -0
- package/src/Overlay/useAnchoredOverlay.ts +197 -0
- package/src/Popover/Popover.mdx +17 -20
- package/src/Popover/Popover.stories.tsx +33 -0
- package/src/Popover/Popover.test.tsx +68 -32
- package/src/Popover/Popover.tsx +56 -95
- package/src/Tooltip/Tooltip.mdx +12 -9
- package/src/Tooltip/Tooltip.test.tsx +17 -19
- package/src/Tooltip/Tooltip.tsx +35 -32
- package/src/index.ts +13 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +12 -3
- package/src/storyControls.test.ts +25 -12
- package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
- package/dist/Modal/lockScrollBehind.js.map +0 -1
- /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
- /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
- /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
|
-
##
|
|
33
|
-
|
|
34
|
-
The panel
|
|
35
|
-
`
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
`
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
package/src/Menu/Menu.test.tsx
CHANGED
|
@@ -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 {
|
|
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(
|
|
29
|
+
const menu = expectAgentDrivable(body, {
|
|
31
30
|
name: "Bay 3",
|
|
32
31
|
role: "menu",
|
|
33
32
|
})
|
|
34
33
|
|
|
35
|
-
expectAgentDrivable(
|
|
34
|
+
expectAgentDrivable(body, {
|
|
36
35
|
name: "Retry title",
|
|
37
36
|
role: "menuitem",
|
|
38
37
|
})
|
|
39
38
|
|
|
40
|
-
await expectNoAxeViolations(
|
|
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(
|
|
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 {
|
|
68
|
+
const { body } = await mountStory(AllStates)
|
|
70
69
|
|
|
71
|
-
const skip = expectAgentDrivable(
|
|
70
|
+
const skip = expectAgentDrivable(body, {
|
|
72
71
|
name: "Skip title",
|
|
73
72
|
role: "menuitem",
|
|
74
73
|
})
|
|
75
74
|
|
|
76
|
-
const eject = expectAgentDrivable(
|
|
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(
|
|
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 {
|
|
120
|
+
const { body } = await mountStory(AllStates)
|
|
122
121
|
|
|
123
|
-
const menu = expectAgentDrivable(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
183
|
-
0,
|
|
184
|
-
)
|
|
179
|
+
expect(body.queryAllByRole("menuitem")).toHaveLength(0)
|
|
185
180
|
})
|
|
186
181
|
})
|
|
187
182
|
|
|
188
183
|
/**
|
|
189
|
-
* The panel
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
306
|
+
expectAgentDrivable(body, { role: "tooltip" }),
|
|
298
307
|
)
|
|
299
308
|
|
|
300
309
|
// The **main axis** only: `top` with `offset(6)`. The cross axis
|