@charcuterie/ui 1.0.1 → 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/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
- package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
- package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
- 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 +57 -0
- package/dist/Lightbox/Lightbox.d.ts.map +1 -0
- package/dist/Lightbox/Lightbox.js +50 -0
- package/dist/Lightbox/Lightbox.js.map +1 -0
- 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 +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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 +5 -4
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
- package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
- package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
- 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 +63 -0
- package/src/Lightbox/Lightbox.stories.tsx +133 -0
- package/src/Lightbox/Lightbox.test.tsx +124 -0
- package/src/Lightbox/Lightbox.tsx +143 -0
- 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/icons.storyHelpers.tsx +22 -0
- package/src/index.ts +22 -4
- package/src/mountStory.testHelpers.ts +25 -1
- package/src/sourceRules.test.ts +26 -1
- 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,205 @@
|
|
|
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 { placementArgType } from "../argTypes.storyHelpers.ts"
|
|
7
|
+
import { Badge } from "../Badge/Badge.tsx"
|
|
8
|
+
import { Button } from "../Button/Button.tsx"
|
|
9
|
+
import { StoryRow } from "../board.storyHelpers.tsx"
|
|
10
|
+
import type { ListboxItem } from "./Listbox.tsx"
|
|
11
|
+
import { Listbox } from "./Listbox.tsx"
|
|
12
|
+
|
|
13
|
+
const LANGUAGES: ListboxItem[] = [
|
|
14
|
+
{ label: "English", value: "eng" },
|
|
15
|
+
{ label: "Spanish", value: "spa" },
|
|
16
|
+
{ label: "French", value: "fra" },
|
|
17
|
+
{ isDisabled: true, label: "German", value: "deu" },
|
|
18
|
+
{ label: "Japanese", value: "jpn" },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The rich options a native `<option>` cannot render — a leading
|
|
23
|
+
* flag, a two-line detail, a trailing confidence badge — each with a
|
|
24
|
+
* `textValue` so type-ahead and the accessible name still work.
|
|
25
|
+
*/
|
|
26
|
+
const TRACKS: ListboxItem[] = [
|
|
27
|
+
{
|
|
28
|
+
label: (
|
|
29
|
+
<span className="flex items-center gap-2">
|
|
30
|
+
<span aria-hidden="true">🇬🇧</span>
|
|
31
|
+
<span className="flex flex-col">
|
|
32
|
+
<span>English</span>
|
|
33
|
+
<span className="text-content-muted text-xs">
|
|
34
|
+
5.1 · forced
|
|
35
|
+
</span>
|
|
36
|
+
</span>
|
|
37
|
+
</span>
|
|
38
|
+
),
|
|
39
|
+
textValue: "English",
|
|
40
|
+
value: "eng",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
label: (
|
|
44
|
+
<span className="flex items-center gap-2">
|
|
45
|
+
<span aria-hidden="true">🇯🇵</span>
|
|
46
|
+
<span className="flex flex-col">
|
|
47
|
+
<span>Japanese</span>
|
|
48
|
+
<span className="text-content-muted text-xs">
|
|
49
|
+
2.0 · original
|
|
50
|
+
</span>
|
|
51
|
+
</span>
|
|
52
|
+
</span>
|
|
53
|
+
),
|
|
54
|
+
textValue: "Japanese",
|
|
55
|
+
value: "jpn",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: (
|
|
59
|
+
<span className="flex items-center gap-2">
|
|
60
|
+
<span aria-hidden="true">🇫🇷</span>
|
|
61
|
+
<span>French</span>
|
|
62
|
+
<Badge intent="warning" size="sm">
|
|
63
|
+
low confidence
|
|
64
|
+
</Badge>
|
|
65
|
+
</span>
|
|
66
|
+
),
|
|
67
|
+
textValue: "French",
|
|
68
|
+
value: "fra",
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
const meta = {
|
|
73
|
+
title: "Components/Listbox",
|
|
74
|
+
component: Listbox,
|
|
75
|
+
parameters: { layout: "padded" },
|
|
76
|
+
argTypes: { placement: placementArgType },
|
|
77
|
+
args: { placement: "bottom-start" },
|
|
78
|
+
} satisfies Meta<typeof Listbox>
|
|
79
|
+
|
|
80
|
+
export default meta
|
|
81
|
+
|
|
82
|
+
type Story = StoryObj<typeof meta>
|
|
83
|
+
|
|
84
|
+
const ListboxHarness = ({
|
|
85
|
+
initialSelected,
|
|
86
|
+
options,
|
|
87
|
+
triggerLabel,
|
|
88
|
+
}: {
|
|
89
|
+
initialSelected?: string
|
|
90
|
+
options: ListboxItem[]
|
|
91
|
+
triggerLabel: string
|
|
92
|
+
}): ReactNode => {
|
|
93
|
+
const { hide, isVisible, toggle } = useVisibility()
|
|
94
|
+
|
|
95
|
+
const [selected, setSelected] = useState<
|
|
96
|
+
string | undefined
|
|
97
|
+
>(initialSelected)
|
|
98
|
+
|
|
99
|
+
const current = options.find(
|
|
100
|
+
(option) => option.value === selected,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const label =
|
|
104
|
+
current === undefined
|
|
105
|
+
? triggerLabel
|
|
106
|
+
: (current.textValue ??
|
|
107
|
+
(typeof current.label === "string"
|
|
108
|
+
? current.label
|
|
109
|
+
: triggerLabel))
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Listbox
|
|
113
|
+
isVisible={isVisible}
|
|
114
|
+
onDismiss={hide}
|
|
115
|
+
onSelect={setSelected}
|
|
116
|
+
options={options}
|
|
117
|
+
selectedValue={selected}
|
|
118
|
+
trigger={
|
|
119
|
+
<Button appearance="outline" onClick={toggle}>
|
|
120
|
+
{label}
|
|
121
|
+
</Button>
|
|
122
|
+
}
|
|
123
|
+
/>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const Default: Story = {
|
|
128
|
+
args: {
|
|
129
|
+
isVisible: false,
|
|
130
|
+
onDismiss: () => {},
|
|
131
|
+
onSelect: () => {},
|
|
132
|
+
options: LANGUAGES,
|
|
133
|
+
trigger: <Button>Choose a language</Button>,
|
|
134
|
+
},
|
|
135
|
+
render: () => (
|
|
136
|
+
<ListboxHarness
|
|
137
|
+
options={LANGUAGES}
|
|
138
|
+
triggerLabel="Choose a language"
|
|
139
|
+
/>
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A disabled option stays in the list and stays announced — it joins
|
|
145
|
+
* the choice but not the focus group, so the arrow keys and type-ahead
|
|
146
|
+
* skip it. `Spanish` is seeded as the current value.
|
|
147
|
+
*/
|
|
148
|
+
export const AllStates: Story = {
|
|
149
|
+
args: {
|
|
150
|
+
isVisible: false,
|
|
151
|
+
onDismiss: () => {},
|
|
152
|
+
onSelect: () => {},
|
|
153
|
+
options: LANGUAGES,
|
|
154
|
+
trigger: <Button>Choose a language</Button>,
|
|
155
|
+
},
|
|
156
|
+
render: () => (
|
|
157
|
+
<ListboxHarness
|
|
158
|
+
initialSelected="spa"
|
|
159
|
+
options={LANGUAGES}
|
|
160
|
+
triggerLabel="Choose a language"
|
|
161
|
+
/>
|
|
162
|
+
),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The thing a native `<select>` cannot do: two-line options, a leading
|
|
167
|
+
* flag, and a trailing intent-coloured badge.
|
|
168
|
+
*/
|
|
169
|
+
export const AllVariants: Story = {
|
|
170
|
+
args: {
|
|
171
|
+
isVisible: false,
|
|
172
|
+
onDismiss: () => {},
|
|
173
|
+
onSelect: () => {},
|
|
174
|
+
options: TRACKS,
|
|
175
|
+
trigger: <Button>Choose a track</Button>,
|
|
176
|
+
},
|
|
177
|
+
render: () => (
|
|
178
|
+
<StoryRow>
|
|
179
|
+
<ListboxHarness
|
|
180
|
+
options={TRACKS}
|
|
181
|
+
triggerLabel="Choose a track"
|
|
182
|
+
/>
|
|
183
|
+
</StoryRow>
|
|
184
|
+
),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Open it and drive it from the keyboard: arrows move focus without
|
|
189
|
+
* selecting, type a letter to jump, Enter chooses, Escape closes.
|
|
190
|
+
*/
|
|
191
|
+
export const Interactive: Story = {
|
|
192
|
+
args: {
|
|
193
|
+
isVisible: false,
|
|
194
|
+
onDismiss: () => {},
|
|
195
|
+
onSelect: () => {},
|
|
196
|
+
options: LANGUAGES,
|
|
197
|
+
trigger: <Button>Choose a language</Button>,
|
|
198
|
+
},
|
|
199
|
+
render: () => (
|
|
200
|
+
<ListboxHarness
|
|
201
|
+
options={LANGUAGES}
|
|
202
|
+
triggerLabel="Choose a language"
|
|
203
|
+
/>
|
|
204
|
+
),
|
|
205
|
+
}
|
|
@@ -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
|
+
}
|