@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.
- 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 +47 -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 +347 -0
- package/src/Dialog/Dialog.test.tsx +312 -0
- package/src/Dialog/Dialog.tsx +127 -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,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
|
-
##
|
|
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
|
|