@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,245 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { useState } from "react"
|
|
5
|
+
|
|
6
|
+
import { Button } from "../Button/Button.tsx"
|
|
7
|
+
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
8
|
+
import { Combobox } from "./Combobox.tsx"
|
|
9
|
+
|
|
10
|
+
const LANGUAGES: ListboxItem[] = [
|
|
11
|
+
{ label: "English", value: "eng" },
|
|
12
|
+
{ label: "Spanish", value: "spa" },
|
|
13
|
+
{ label: "French", value: "fra" },
|
|
14
|
+
{ label: "German", value: "deu" },
|
|
15
|
+
{ label: "Japanese", value: "jpn" },
|
|
16
|
+
{ label: "Korean", value: "kor" },
|
|
17
|
+
{ label: "Portuguese", value: "por" },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const LANGUAGES_DISABLED_FIRST: ListboxItem[] = [
|
|
21
|
+
{ isDisabled: true, label: "English", value: "eng" },
|
|
22
|
+
{ label: "Spanish", value: "spa" },
|
|
23
|
+
{ label: "French", value: "fra" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// A long list, to trip the auto-virtualization threshold.
|
|
27
|
+
const MANY: ListboxItem[] = Array.from(
|
|
28
|
+
{ length: 500 },
|
|
29
|
+
(_unused, index) => ({
|
|
30
|
+
label: `Track ${index + 1}`,
|
|
31
|
+
textValue: `Track ${index + 1}`,
|
|
32
|
+
value: `track-${index + 1}`,
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: "Components/Combobox",
|
|
38
|
+
component: Combobox,
|
|
39
|
+
parameters: { layout: "padded" },
|
|
40
|
+
} satisfies Meta<typeof Combobox>
|
|
41
|
+
|
|
42
|
+
export default meta
|
|
43
|
+
|
|
44
|
+
type Story = StoryObj<typeof meta>
|
|
45
|
+
|
|
46
|
+
const ComboboxHarness = ({
|
|
47
|
+
emptyLabel,
|
|
48
|
+
error,
|
|
49
|
+
footer,
|
|
50
|
+
isCreatable,
|
|
51
|
+
isInitiallyVisible = false,
|
|
52
|
+
isLoading,
|
|
53
|
+
isMultiple,
|
|
54
|
+
isVirtualized,
|
|
55
|
+
options,
|
|
56
|
+
triggerLabel,
|
|
57
|
+
}: {
|
|
58
|
+
emptyLabel?: string
|
|
59
|
+
error?: ReactNode
|
|
60
|
+
footer?: ReactNode
|
|
61
|
+
isCreatable?: boolean
|
|
62
|
+
isInitiallyVisible?: boolean
|
|
63
|
+
isLoading?: boolean
|
|
64
|
+
isMultiple?: boolean
|
|
65
|
+
isVirtualized?: boolean
|
|
66
|
+
options: ListboxItem[]
|
|
67
|
+
triggerLabel: string
|
|
68
|
+
}): ReactNode => {
|
|
69
|
+
const { hide, isVisible, toggle } = useVisibility({
|
|
70
|
+
isVisible: isInitiallyVisible,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const [chosen, setChosen] = useState<string[]>([])
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<Combobox
|
|
78
|
+
emptyLabel={emptyLabel}
|
|
79
|
+
error={error}
|
|
80
|
+
footer={footer}
|
|
81
|
+
isCreatable={isCreatable}
|
|
82
|
+
isLoading={isLoading}
|
|
83
|
+
isMultiple={isMultiple}
|
|
84
|
+
isVirtualized={isVirtualized}
|
|
85
|
+
isVisible={isVisible}
|
|
86
|
+
onDismiss={hide}
|
|
87
|
+
onSelect={(value) => {
|
|
88
|
+
setChosen((previous) =>
|
|
89
|
+
previous.includes(value)
|
|
90
|
+
? previous.filter((one) => one !== value)
|
|
91
|
+
: [...previous, value],
|
|
92
|
+
)
|
|
93
|
+
}}
|
|
94
|
+
options={options}
|
|
95
|
+
trigger={
|
|
96
|
+
<Button appearance="outline" onClick={toggle}>
|
|
97
|
+
{triggerLabel}
|
|
98
|
+
</Button>
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
|
|
102
|
+
<p className="mt-2 text-content-muted text-xs">
|
|
103
|
+
Chosen: {chosen.join(", ") || "—"}
|
|
104
|
+
</p>
|
|
105
|
+
</>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const Default: Story = {
|
|
110
|
+
args: {
|
|
111
|
+
isVisible: false,
|
|
112
|
+
onDismiss: () => {},
|
|
113
|
+
onSelect: () => {},
|
|
114
|
+
options: LANGUAGES,
|
|
115
|
+
trigger: <Button>Search languages</Button>,
|
|
116
|
+
},
|
|
117
|
+
render: () => (
|
|
118
|
+
<ComboboxHarness
|
|
119
|
+
options={LANGUAGES}
|
|
120
|
+
triggerLabel="Search languages"
|
|
121
|
+
/>
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The loading and error panel states — the ones only `PathPicker` had
|
|
127
|
+
* all of, and every fleet picker did differently. Both keep the popup
|
|
128
|
+
* open.
|
|
129
|
+
*/
|
|
130
|
+
export const AllStates: Story = {
|
|
131
|
+
args: {
|
|
132
|
+
isVisible: false,
|
|
133
|
+
onDismiss: () => {},
|
|
134
|
+
onSelect: () => {},
|
|
135
|
+
options: LANGUAGES,
|
|
136
|
+
trigger: <Button>Search</Button>,
|
|
137
|
+
},
|
|
138
|
+
render: () => (
|
|
139
|
+
<div className="flex flex-col gap-8">
|
|
140
|
+
<ComboboxHarness
|
|
141
|
+
isLoading
|
|
142
|
+
options={LANGUAGES}
|
|
143
|
+
triggerLabel="Loading state"
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
<ComboboxHarness
|
|
147
|
+
error="Could not reach the server."
|
|
148
|
+
options={LANGUAGES}
|
|
149
|
+
triggerLabel="Error state"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Multi-select renders the chosen values as removable chips rather
|
|
157
|
+
* than filling the input, with a sticky footer hint — the
|
|
158
|
+
* `LanguageCodeField` and `LinkPicker` shapes. `isCreatable` lets Enter
|
|
159
|
+
* commit the raw query.
|
|
160
|
+
*/
|
|
161
|
+
export const AllVariants: Story = {
|
|
162
|
+
args: {
|
|
163
|
+
isVisible: false,
|
|
164
|
+
onDismiss: () => {},
|
|
165
|
+
onSelect: () => {},
|
|
166
|
+
options: LANGUAGES,
|
|
167
|
+
trigger: <Button>Pick languages</Button>,
|
|
168
|
+
},
|
|
169
|
+
render: () => (
|
|
170
|
+
<ComboboxHarness
|
|
171
|
+
footer="Type a code and press Enter to add it."
|
|
172
|
+
isCreatable
|
|
173
|
+
isMultiple
|
|
174
|
+
options={LANGUAGES}
|
|
175
|
+
triggerLabel="Pick languages"
|
|
176
|
+
/>
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Five hundred options, windowed with `@tanstack/react-virtual`: only
|
|
182
|
+
* the visible rows are in the DOM, so each carries `aria-setsize` and
|
|
183
|
+
* `aria-posinset` — otherwise a screen reader announces "2 of 12" for
|
|
184
|
+
* a list that is nothing of the sort.
|
|
185
|
+
*/
|
|
186
|
+
export const Virtualized: Story = {
|
|
187
|
+
args: {
|
|
188
|
+
isVisible: false,
|
|
189
|
+
onDismiss: () => {},
|
|
190
|
+
onSelect: () => {},
|
|
191
|
+
options: MANY,
|
|
192
|
+
trigger: <Button>Search 500 tracks</Button>,
|
|
193
|
+
},
|
|
194
|
+
render: () => (
|
|
195
|
+
<ComboboxHarness
|
|
196
|
+
isInitiallyVisible
|
|
197
|
+
options={MANY}
|
|
198
|
+
triggerLabel="Search 500 tracks"
|
|
199
|
+
/>
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Open it and type: the list filters as you go, arrows move the active
|
|
205
|
+
* option while the caret stays in the input, Enter chooses, and Escape
|
|
206
|
+
* clears the query before it closes.
|
|
207
|
+
*/
|
|
208
|
+
export const Interactive: Story = {
|
|
209
|
+
args: {
|
|
210
|
+
isVisible: false,
|
|
211
|
+
onDismiss: () => {},
|
|
212
|
+
onSelect: () => {},
|
|
213
|
+
options: LANGUAGES,
|
|
214
|
+
trigger: <Button>Search languages</Button>,
|
|
215
|
+
},
|
|
216
|
+
render: () => (
|
|
217
|
+
<ComboboxHarness
|
|
218
|
+
options={LANGUAGES}
|
|
219
|
+
triggerLabel="Search languages"
|
|
220
|
+
/>
|
|
221
|
+
),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A disabled option in the seed position. Opening seeds the active row at
|
|
226
|
+
* index 0, so the resolved active descendant must skip past the disabled
|
|
227
|
+
* first option to the first enabled one — and Enter must never commit the
|
|
228
|
+
* disabled value.
|
|
229
|
+
*/
|
|
230
|
+
export const DisabledFirstOption: Story = {
|
|
231
|
+
args: {
|
|
232
|
+
isVisible: false,
|
|
233
|
+
onDismiss: () => {},
|
|
234
|
+
onSelect: () => {},
|
|
235
|
+
options: LANGUAGES_DISABLED_FIRST,
|
|
236
|
+
trigger: <Button>Pick a language</Button>,
|
|
237
|
+
},
|
|
238
|
+
render: () => (
|
|
239
|
+
<ComboboxHarness
|
|
240
|
+
isInitiallyVisible
|
|
241
|
+
options={LANGUAGES_DISABLED_FIRST}
|
|
242
|
+
triggerLabel="Pick a language"
|
|
243
|
+
/>
|
|
244
|
+
),
|
|
245
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
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 "./Combobox.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
AllVariants,
|
|
12
|
+
Default,
|
|
13
|
+
DisabledFirstOption,
|
|
14
|
+
Interactive,
|
|
15
|
+
Virtualized,
|
|
16
|
+
} = composeStories(stories)
|
|
17
|
+
|
|
18
|
+
const openMulti = async () => {
|
|
19
|
+
const mounted = await mountStory(AllVariants)
|
|
20
|
+
|
|
21
|
+
await userEvent.click(
|
|
22
|
+
expectAgentDrivable(mounted.canvas, {
|
|
23
|
+
name: "Pick languages",
|
|
24
|
+
role: "button",
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const input = expectAgentDrivable(mounted.body, {
|
|
29
|
+
role: "combobox",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
await waitFor(() => {
|
|
33
|
+
expect(input).toHaveFocus()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return { ...mounted, input }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const openDefault = async () => {
|
|
40
|
+
const mounted = await mountStory(Default)
|
|
41
|
+
|
|
42
|
+
await userEvent.click(
|
|
43
|
+
expectAgentDrivable(mounted.canvas, {
|
|
44
|
+
name: "Search languages",
|
|
45
|
+
role: "button",
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const input = expectAgentDrivable(mounted.body, {
|
|
50
|
+
role: "combobox",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await waitFor(() => {
|
|
54
|
+
expect(input).toHaveFocus()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return { ...mounted, input }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("a button opens a combobox whose popup is a listbox", async () => {
|
|
61
|
+
const { body, input } = await openDefault()
|
|
62
|
+
|
|
63
|
+
await expect(input).toHaveAttribute(
|
|
64
|
+
"aria-autocomplete",
|
|
65
|
+
"list",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const listbox = expectAgentDrivable(body, {
|
|
69
|
+
role: "listbox",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await expect(input).toHaveAttribute(
|
|
73
|
+
"aria-controls",
|
|
74
|
+
listbox.id,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
await expectNoAxeViolations(listbox)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("typing filters the options, and focus stays in the input", async () => {
|
|
81
|
+
const { body, input } = await openDefault()
|
|
82
|
+
|
|
83
|
+
await expect(body.getAllByRole("option")).toHaveLength(7)
|
|
84
|
+
|
|
85
|
+
await userEvent.keyboard("fr")
|
|
86
|
+
|
|
87
|
+
await waitFor(() => {
|
|
88
|
+
expect(body.getAllByRole("option")).toHaveLength(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(body.getByRole("option")).toHaveTextContent(
|
|
92
|
+
"French",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// The caret never left the input.
|
|
96
|
+
await expect(input).toHaveFocus()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("no match shows the empty label and keeps the popup open", async () => {
|
|
100
|
+
const { body } = await openDefault()
|
|
101
|
+
|
|
102
|
+
await userEvent.keyboard("zzz")
|
|
103
|
+
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(body.queryAllByRole("option")).toHaveLength(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Still open, still typeable.
|
|
109
|
+
await expect(
|
|
110
|
+
body.getByText("No matches"),
|
|
111
|
+
).toBeInTheDocument()
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
body.getByRole("combobox"),
|
|
115
|
+
).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("the arrow keys move aria-activedescendant, not focus", async () => {
|
|
119
|
+
const { body, input } = await openDefault()
|
|
120
|
+
|
|
121
|
+
const options = body.getAllByRole("option")
|
|
122
|
+
|
|
123
|
+
// Opens on the first option.
|
|
124
|
+
await expect(input).toHaveAttribute(
|
|
125
|
+
"aria-activedescendant",
|
|
126
|
+
options[0]?.id,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(input).toHaveAttribute(
|
|
133
|
+
"aria-activedescendant",
|
|
134
|
+
options[1]?.id,
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Focus never moved to the option.
|
|
139
|
+
await expect(input).toHaveFocus()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test("Enter selects the active option and closes", async () => {
|
|
143
|
+
const { body, input } = await openDefault()
|
|
144
|
+
|
|
145
|
+
await userEvent.keyboard("{ArrowDown}{Enter}")
|
|
146
|
+
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(body.queryByRole("combobox")).toBeNull()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Single-select flows back through `onSelect`.
|
|
152
|
+
await expect(input).not.toBeInTheDocument()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test("Escape clears the query first, then closes", async () => {
|
|
156
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
157
|
+
|
|
158
|
+
await userEvent.click(
|
|
159
|
+
expectAgentDrivable(canvas, {
|
|
160
|
+
name: "Search languages",
|
|
161
|
+
role: "button",
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const input = body.getByRole("combobox")
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
expect(input).toHaveFocus()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
await userEvent.keyboard("fr")
|
|
172
|
+
|
|
173
|
+
await expect(input).toHaveValue("fr")
|
|
174
|
+
|
|
175
|
+
await userEvent.keyboard("{Escape}")
|
|
176
|
+
|
|
177
|
+
// First Escape clears the query but leaves the popup open.
|
|
178
|
+
await expect(input).toHaveValue("")
|
|
179
|
+
|
|
180
|
+
await expect(
|
|
181
|
+
body.getByRole("combobox"),
|
|
182
|
+
).toBeInTheDocument()
|
|
183
|
+
|
|
184
|
+
await userEvent.keyboard("{Escape}")
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(body.queryByRole("combobox")).toBeNull()
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test("multi-select Tab escapes the field instead of committing", async () => {
|
|
192
|
+
const { body, canvas, input } = await openMulti()
|
|
193
|
+
|
|
194
|
+
// Nothing chosen yet.
|
|
195
|
+
await expect(
|
|
196
|
+
canvas.getByText("Chosen: —"),
|
|
197
|
+
).toBeInTheDocument()
|
|
198
|
+
|
|
199
|
+
await userEvent.tab()
|
|
200
|
+
|
|
201
|
+
// Tab did not commit the active option — no chip, still nothing chosen…
|
|
202
|
+
await expect(
|
|
203
|
+
canvas.getByText("Chosen: —"),
|
|
204
|
+
).toBeInTheDocument()
|
|
205
|
+
|
|
206
|
+
await expect(
|
|
207
|
+
body.queryByRole("button", { name: /^Remove / }),
|
|
208
|
+
).toBeNull()
|
|
209
|
+
|
|
210
|
+
// …and focus left the input rather than being trapped back in it.
|
|
211
|
+
await expect(input).not.toHaveFocus()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test("removing a chip reports the removal through onSelect", async () => {
|
|
215
|
+
const { body, canvas } = await openMulti()
|
|
216
|
+
|
|
217
|
+
// Enter commits the active option in multi-select and keeps the popup
|
|
218
|
+
// open, so a chip appears and the parent is told.
|
|
219
|
+
await userEvent.keyboard("{Enter}")
|
|
220
|
+
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
expect(
|
|
223
|
+
canvas.getByText("Chosen: eng"),
|
|
224
|
+
).toBeInTheDocument()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await userEvent.click(
|
|
228
|
+
body.getByRole("button", { name: "Remove eng" }),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// The removal flowed back through `onSelect`, so the parent cleared it
|
|
232
|
+
// — a chip removal and a list toggle-off now agree.
|
|
233
|
+
await waitFor(() => {
|
|
234
|
+
expect(
|
|
235
|
+
canvas.getByText("Chosen: —"),
|
|
236
|
+
).toBeInTheDocument()
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test("a disabled first option is skipped, never active or committed", async () => {
|
|
241
|
+
const { body, canvas } = await mountStory(
|
|
242
|
+
DisabledFirstOption,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
const input = body.getByRole("combobox")
|
|
246
|
+
|
|
247
|
+
await waitFor(() => {
|
|
248
|
+
expect(input).toHaveFocus()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const options = body.getAllByRole("option")
|
|
252
|
+
|
|
253
|
+
// The seed lands on index 0 (disabled English); the resolved active
|
|
254
|
+
// descendant skips to the first enabled option, Spanish.
|
|
255
|
+
await expect(input).toHaveAttribute(
|
|
256
|
+
"aria-activedescendant",
|
|
257
|
+
options[1]?.id,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
await userEvent.keyboard("{Enter}")
|
|
261
|
+
|
|
262
|
+
// Enter committed Spanish, not the disabled English, and closed.
|
|
263
|
+
await waitFor(() => {
|
|
264
|
+
expect(body.queryByRole("combobox")).toBeNull()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
canvas.getByText("Chosen: spa"),
|
|
269
|
+
).toBeInTheDocument()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test("a windowed list carries aria-setsize and aria-posinset", async () => {
|
|
273
|
+
const { body } = await mountStory(Virtualized)
|
|
274
|
+
|
|
275
|
+
const options = body.getAllByRole("option")
|
|
276
|
+
|
|
277
|
+
// Only a window of the 500 is in the DOM.
|
|
278
|
+
await expect(options.length).toBeLessThan(500)
|
|
279
|
+
|
|
280
|
+
await expect(options.length).toBeGreaterThan(0)
|
|
281
|
+
|
|
282
|
+
// Each announces its place in the whole set, not the window.
|
|
283
|
+
await expect(options[0]).toHaveAttribute(
|
|
284
|
+
"aria-setsize",
|
|
285
|
+
"500",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
await expect(options[0]).toHaveAttribute("aria-posinset")
|
|
289
|
+
})
|