@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
|
@@ -10,23 +10,8 @@ import * as stories from "./Lightbox.stories.tsx"
|
|
|
10
10
|
const { Controlled, Default, Interactive, WithCaption } =
|
|
11
11
|
composeStories(stories)
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
* The close request, dispatched where the browser would raise it.
|
|
15
|
-
* `userEvent`'s Escape is untrusted and a native `<dialog>` runs no
|
|
16
|
-
* default action for it, so — exactly as `Modal`'s tests do — the
|
|
17
|
-
* `cancel` event is dispatched directly, and only the half
|
|
18
|
-
* downstream of it (this component's `onClose`, the effect, the
|
|
19
|
-
* focus restore) is ours to assert.
|
|
20
|
-
*/
|
|
21
|
-
const requestClose = (dialog: HTMLElement) => {
|
|
22
|
-
dialog.dispatchEvent(
|
|
23
|
-
new Event("cancel", { cancelable: true }),
|
|
24
|
-
)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
13
|
test("the thumbnail opens the enlarged image", async () => {
|
|
28
|
-
const {
|
|
29
|
-
await mountStory(Default)
|
|
14
|
+
const { body, canvas } = await mountStory(Default)
|
|
30
15
|
|
|
31
16
|
await userEvent.click(
|
|
32
17
|
expectAgentDrivable(canvas, {
|
|
@@ -35,16 +20,14 @@ test("the thumbnail opens the enlarged image", async () => {
|
|
|
35
20
|
}),
|
|
36
21
|
)
|
|
37
22
|
|
|
38
|
-
const dialog = expectAgentDrivable(
|
|
23
|
+
const dialog = expectAgentDrivable(body, {
|
|
39
24
|
name: "THE OUTFIT poster",
|
|
40
25
|
role: "dialog",
|
|
41
26
|
})
|
|
42
27
|
|
|
43
|
-
await expect(dialog).toHaveAttribute("open")
|
|
44
|
-
|
|
45
28
|
// The enlarged image carries the name — a bare `<img>` in a dialog
|
|
46
29
|
// otherwise announces as nothing.
|
|
47
|
-
expectAgentDrivable(
|
|
30
|
+
expectAgentDrivable(body, {
|
|
48
31
|
name: "THE OUTFIT poster",
|
|
49
32
|
role: "img",
|
|
50
33
|
})
|
|
@@ -52,11 +35,11 @@ test("the thumbnail opens the enlarged image", async () => {
|
|
|
52
35
|
// The open dialog audits itself: the empty-alt thumbnail, the
|
|
53
36
|
// named trigger and the named enlarged image are the arrangement
|
|
54
37
|
// most likely to trip `button-name` or a duplicate name.
|
|
55
|
-
await expectNoAxeViolations(
|
|
38
|
+
await expectNoAxeViolations(dialog)
|
|
56
39
|
})
|
|
57
40
|
|
|
58
41
|
test("the caption rides along with the enlarged image", async () => {
|
|
59
|
-
const { canvas } = await mountStory(WithCaption)
|
|
42
|
+
const { body, canvas } = await mountStory(WithCaption)
|
|
60
43
|
|
|
61
44
|
await userEvent.click(
|
|
62
45
|
canvas.getByRole("button", {
|
|
@@ -65,17 +48,19 @@ test("the caption rides along with the enlarged image", async () => {
|
|
|
65
48
|
)
|
|
66
49
|
|
|
67
50
|
await expect(
|
|
68
|
-
|
|
51
|
+
body.getByText("2022 · Blu-ray · 1080p"),
|
|
69
52
|
).toBeInTheDocument()
|
|
70
53
|
})
|
|
71
54
|
|
|
72
55
|
/**
|
|
73
|
-
* The keyboard contract, inherited from `
|
|
56
|
+
* The keyboard contract, inherited from `Dialog` and asserted here so
|
|
74
57
|
* the skin cannot quietly drop it — Escape routes through `onClose`,
|
|
75
|
-
* and focus returns to the trigger
|
|
58
|
+
* and focus returns to the trigger `FloatingFocusManager` restored it
|
|
59
|
+
* to. Escape is a real `userEvent` keypress now, since the overlay is
|
|
60
|
+
* no longer a native `<dialog>`.
|
|
76
61
|
*/
|
|
77
62
|
test("closing restores focus to the thumbnail trigger", async () => {
|
|
78
|
-
const { canvas } = await mountStory(Interactive)
|
|
63
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
79
64
|
|
|
80
65
|
const trigger = expectAgentDrivable(canvas, {
|
|
81
66
|
name: "Enlarge THE OUTFIT poster",
|
|
@@ -84,19 +69,21 @@ test("closing restores focus to the thumbnail trigger", async () => {
|
|
|
84
69
|
|
|
85
70
|
await userEvent.click(trigger)
|
|
86
71
|
|
|
87
|
-
const dialog = expectAgentDrivable(
|
|
72
|
+
const dialog = expectAgentDrivable(body, {
|
|
88
73
|
name: "THE OUTFIT poster",
|
|
89
74
|
role: "dialog",
|
|
90
75
|
})
|
|
91
76
|
|
|
92
|
-
await
|
|
93
|
-
document.activeElement
|
|
94
|
-
|
|
77
|
+
await waitFor(() => {
|
|
78
|
+
expect(dialog.contains(document.activeElement)).toBe(
|
|
79
|
+
true,
|
|
80
|
+
)
|
|
81
|
+
})
|
|
95
82
|
|
|
96
|
-
|
|
83
|
+
await userEvent.keyboard("{Escape}")
|
|
97
84
|
|
|
98
85
|
await waitFor(() => {
|
|
99
|
-
expect(
|
|
86
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
100
87
|
})
|
|
101
88
|
|
|
102
89
|
await expect(document.activeElement).toBe(trigger)
|
|
@@ -108,7 +95,7 @@ test("closing restores focus to the thumbnail trigger", async () => {
|
|
|
108
95
|
* opens it, and closing it flows back through `onOpenChange`.
|
|
109
96
|
*/
|
|
110
97
|
test("a controlled lightbox opens from an outside button", async () => {
|
|
111
|
-
const { canvas } = await mountStory(Controlled)
|
|
98
|
+
const { body, canvas } = await mountStory(Controlled)
|
|
112
99
|
|
|
113
100
|
// No built-in trigger: the only "Enlarge …" button a `thumbnail`
|
|
114
101
|
// would have added is absent.
|
|
@@ -122,14 +109,16 @@ test("a controlled lightbox opens from an outside button", async () => {
|
|
|
122
109
|
canvas.getByRole("button", { name: "View the poster" }),
|
|
123
110
|
)
|
|
124
111
|
|
|
125
|
-
const dialog = expectAgentDrivable(
|
|
112
|
+
const dialog = expectAgentDrivable(body, {
|
|
126
113
|
name: "THE OUTFIT poster",
|
|
127
114
|
role: "dialog",
|
|
128
115
|
})
|
|
129
116
|
|
|
130
|
-
|
|
117
|
+
await userEvent.keyboard("{Escape}")
|
|
131
118
|
|
|
132
119
|
await waitFor(() => {
|
|
133
|
-
expect(
|
|
120
|
+
expect(body.queryByRole("dialog")).toBeNull()
|
|
134
121
|
})
|
|
122
|
+
|
|
123
|
+
await expect(dialog).not.toBeInTheDocument()
|
|
135
124
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useVisibility } from "@charcuterie/logic"
|
|
2
2
|
import type { ReactNode } from "react"
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { Dialog } from "../Dialog/Dialog.tsx"
|
|
5
5
|
import { toClassName } from "../toClassName.ts"
|
|
6
6
|
|
|
7
7
|
export type LightboxProps = {
|
|
@@ -43,13 +43,13 @@ export type LightboxProps = {
|
|
|
43
43
|
/**
|
|
44
44
|
* A thumbnail that opens its own full-size view — the one image
|
|
45
45
|
* interaction three apps hand-roll and none get the focus trap or
|
|
46
|
-
* the scroll lock right. It is a **skin over `
|
|
47
|
-
* dialog: `
|
|
48
|
-
*
|
|
49
|
-
* makes those correct in every browser the fleet
|
|
50
|
-
* adds is the image-specific part — a `cursor-zoom-in`
|
|
51
|
-
* an `object-contain` image clamped to the viewport so a
|
|
52
|
-
* and a 16:9 still both land whole.
|
|
46
|
+
* the scroll lock right. It is a **skin over `Dialog`**, not a second
|
|
47
|
+
* dialog: `Dialog` (through `Modal`/`OverlayPanel`) owns the portal,
|
|
48
|
+
* the scrim, Escape, the focus trap and `lockScrollBehind`, and
|
|
49
|
+
* everything that makes those correct in every browser the fleet
|
|
50
|
+
* runs. What this adds is the image-specific part — a `cursor-zoom-in`
|
|
51
|
+
* trigger and an `object-contain` image clamped to the viewport so a
|
|
52
|
+
* 2:3 poster and a 16:9 still both land whole.
|
|
53
53
|
*
|
|
54
54
|
* ### Controlled and not, one component
|
|
55
55
|
*
|
|
@@ -115,7 +115,7 @@ export const Lightbox = ({
|
|
|
115
115
|
</button>
|
|
116
116
|
)}
|
|
117
117
|
|
|
118
|
-
<
|
|
118
|
+
<Dialog
|
|
119
119
|
heading={heading ?? alt}
|
|
120
120
|
isVisible={isShown}
|
|
121
121
|
onClose={requestClose}
|
|
@@ -137,7 +137,7 @@ export const Lightbox = ({
|
|
|
137
137
|
</figcaption>
|
|
138
138
|
) : null}
|
|
139
139
|
</figure>
|
|
140
|
-
</
|
|
140
|
+
</Dialog>
|
|
141
141
|
</>
|
|
142
142
|
)
|
|
143
143
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as ListboxStories from "./Listbox.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={ListboxStories} />
|
|
6
|
+
|
|
7
|
+
# Listbox
|
|
8
|
+
|
|
9
|
+
The single-select **fancy** dropdown — the rich-option sibling of the native
|
|
10
|
+
[`Select`](?path=/docs/components-select--docs), not a replacement for it. `Select` keeps
|
|
11
|
+
type-ahead, Home/End, the mobile wheel picker, form submission, `:invalid` and autofill;
|
|
12
|
+
`Listbox` is for the cases `<option>` cannot render — a leading icon, two lines, a trailing
|
|
13
|
+
badge ([decision](../../../docs/decisions/2026-08-03-listbox-and-combobox-are-siblings-of-select.md)).
|
|
14
|
+
|
|
15
|
+
<Canvas of={ListboxStories.Default} />
|
|
16
|
+
|
|
17
|
+
## Props
|
|
18
|
+
|
|
19
|
+
<Controls />
|
|
20
|
+
|
|
21
|
+
## A listbox is not a menu
|
|
22
|
+
|
|
23
|
+
An `option` **is** something you are choosing; a `menuitem` **does** something. So a
|
|
24
|
+
`Listbox` carries `aria-selected`, it type-aheads (the APG requires it of a listbox and
|
|
25
|
+
`Menu` deliberately omits it), and a screen reader announces "listbox, selected, 2 of 4".
|
|
26
|
+
That is the distinction `Menu` documents from the other side.
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
const { hide, isVisible, toggle } = useVisibility()
|
|
30
|
+
const [value, setValue] = useState<string>()
|
|
31
|
+
|
|
32
|
+
<Listbox
|
|
33
|
+
isVisible={isVisible}
|
|
34
|
+
onDismiss={hide}
|
|
35
|
+
onSelect={setValue}
|
|
36
|
+
options={LANGUAGES}
|
|
37
|
+
selectedValue={value}
|
|
38
|
+
trigger={<Button onClick={toggle}>{label}</Button>}
|
|
39
|
+
/>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Two kinds of state, composed
|
|
43
|
+
|
|
44
|
+
`useRovingFocus` owns which option is tabbable — exactly one, so Tab enters and leaves the
|
|
45
|
+
group once and the arrow keys move within it — and `useSinglePicker` owns which option is
|
|
46
|
+
chosen. They are the same two kinds `Tabs` composes, and they never disturb each other:
|
|
47
|
+
arrowing moves focus without selecting, Enter selects the focused option. A disabled option
|
|
48
|
+
joins the choice but not the focus group, so the arrow keys and type-ahead skip it.
|
|
49
|
+
|
|
50
|
+
<Canvas of={ListboxStories.AllStates} />
|
|
51
|
+
|
|
52
|
+
## Rich options, and `textValue`
|
|
53
|
+
|
|
54
|
+
The options take a `ReactNode` label — two lines, a flag, an intent-coloured confidence
|
|
55
|
+
badge. When the label is rich, `textValue` is required: it is the type-ahead target and the
|
|
56
|
+
name a screen reader reads, so a picture of a value is still a value you can find by typing.
|
|
57
|
+
|
|
58
|
+
<Canvas of={ListboxStories.AllVariants} />
|
|
59
|
+
|
|
60
|
+
## Portalled, and a plain button opens it
|
|
61
|
+
|
|
62
|
+
The panel portals to `document.body`. The trigger stays a native **button** that gains
|
|
63
|
+
`aria-haspopup="listbox"`, `aria-expanded`, and `aria-controls` — the button-opens-listbox
|
|
64
|
+
pattern, not a `combobox` (that is `Combobox`, where focus stays in a text input). So there
|
|
65
|
+
is no `heading` prop; the button names the widget. The panel height is clamped to the
|
|
66
|
+
viewport by floating-ui's `size` middleware and scrolls within it.
|
|
67
|
+
|
|
68
|
+
<Canvas of={ListboxStories.Interactive} />
|
|
@@ -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
|
+
})
|