@charcuterie/ui 0.1.1 → 1.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/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- package/package.json +3 -3
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- package/src/sourceRules.test.ts +221 -30
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as MenuStories from "./Menu.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={MenuStories} />
|
|
6
|
+
|
|
7
|
+
# Menu
|
|
8
|
+
|
|
9
|
+
A menu is **not** a listbox, and mux-magic's `TypePicker` is the fleet's one attempt at
|
|
10
|
+
telling them apart — it renders `role="menu"` over items that set a value, which is a
|
|
11
|
+
listbox wearing the wrong role.
|
|
12
|
+
|
|
13
|
+
<Canvas of={MenuStories.Default} />
|
|
14
|
+
|
|
15
|
+
## Props
|
|
16
|
+
|
|
17
|
+
<Controls />
|
|
18
|
+
|
|
19
|
+
## `menuitem` versus `option`
|
|
20
|
+
|
|
21
|
+
| | `menuitem` | `option` |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| What it is | an **action** — Retry, Skip, Eject | a **value** you are choosing |
|
|
24
|
+
| Announced as | "menu, 3 items" | "listbox, selected, 2 of 4" |
|
|
25
|
+
| Leaves state behind | no | yes |
|
|
26
|
+
| Lives in | this component | `Select` |
|
|
27
|
+
|
|
28
|
+
The last row is why it matters to an agent driving the app: after choosing an `option` it
|
|
29
|
+
needs to know what the control now holds. After invoking a `menuitem` there is nothing to
|
|
30
|
+
read back. Getting the role wrong is invisible in a browser and invisible to axe.
|
|
31
|
+
|
|
32
|
+
## A `[popover]` that was never shown is `display: none`
|
|
33
|
+
|
|
34
|
+
The panel calls `showPopover()` from its ref callback, like `Popover` does — plus an
|
|
35
|
+
`isConnected` guard and an effect that `Popover` does not need.
|
|
36
|
+
|
|
37
|
+
`FloatingFocusManager` renders a pair of focus guards around its child, so React commits this
|
|
38
|
+
subtree in an order where the ref can fire while the node is still **detached**.
|
|
39
|
+
`showPopover()` on a disconnected element throws `InvalidStateError`, which surfaces as an
|
|
40
|
+
unhandled rejection rather than a failing assertion — and if it is simply swallowed, the menu
|
|
41
|
+
renders, typechecks, and is invisible to `getByRole` and to a user alike.
|
|
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.
|
|
45
|
+
|
|
46
|
+
## There is no `label` prop, and that corrects a lesson
|
|
47
|
+
|
|
48
|
+
`Spinner` (M3), `Popover` and `Modal` (M4) all take a **required** name, because a
|
|
49
|
+
`role="status"` and a `role="dialog"` take none from their content. The obvious move here was
|
|
50
|
+
a `label` prop doing the same job — it was written that way first, and then measured as
|
|
51
|
+
doing **nothing**.
|
|
52
|
+
|
|
53
|
+
`useRole(context, { role: "menu" })` puts `aria-labelledby` on the panel pointing at the
|
|
54
|
+
trigger, and `aria-labelledby` beats `aria-label`. The menu was already named "Bay 3", from
|
|
55
|
+
the button, and the prop was silently discarded. That is also the APG menu-button pattern.
|
|
56
|
+
|
|
57
|
+
So the rule the other three teach is narrower than it looked:
|
|
58
|
+
|
|
59
|
+
> An overlay with **no trigger relationship** needs its own name.
|
|
60
|
+
|
|
61
|
+
A menu has one, so the requirement moves to the trigger — which `Button` and `IconButton`
|
|
62
|
+
already enforce.
|
|
63
|
+
|
|
64
|
+
## One kind, and that is the other half of the `Tabs` argument
|
|
65
|
+
|
|
66
|
+
`Tabs` needed **two** state kinds because a tab is chosen *and* focused, and in `manual`
|
|
67
|
+
activation those two disagree on purpose. A menu needs **one** — `RovingFocus` — because a
|
|
68
|
+
menu item is never selected, only invoked.
|
|
69
|
+
|
|
70
|
+
Two components, opposite conclusions, same model. That is the composition claim being paid
|
|
71
|
+
off rather than asserted.
|
|
72
|
+
|
|
73
|
+
<Canvas of={MenuStories.AllVariants} />
|
|
74
|
+
|
|
75
|
+
## Focus moves in, once
|
|
76
|
+
|
|
77
|
+
A menu is one of the few patterns where focus must jump on open, or the keyboard user who
|
|
78
|
+
pressed Enter is still on the trigger with a menu they cannot reach.
|
|
79
|
+
|
|
80
|
+
It happens in the panel's own effect rather than through `FloatingFocusManager`'s
|
|
81
|
+
`initialFocus`, because the element to focus is whichever one `RovingFocus` says is active —
|
|
82
|
+
and that is not known until the items have registered. `initialFocus={-1}` turns the
|
|
83
|
+
manager's own aim off, so there is one owner for the caret.
|
|
84
|
+
|
|
85
|
+
`modal={false}`, so Tab **leaves** the menu and lands on the next control in the page, which
|
|
86
|
+
is what closes it. A trapped menu is a dialog.
|
|
87
|
+
|
|
88
|
+
<Canvas of={MenuStories.AllStates} />
|
|
89
|
+
|
|
90
|
+
## Registration is membership
|
|
91
|
+
|
|
92
|
+
A disabled item never registers with the roving group, so the arrow keys skip it with
|
|
93
|
+
nothing in `RovingFocus` knowing what "disabled" means. It stays in the DOM and stays
|
|
94
|
+
announced — "not right now", rather than "does not exist".
|
|
95
|
+
|
|
96
|
+
## No type-ahead, deliberately
|
|
97
|
+
|
|
98
|
+
The APG lists type-ahead as **optional** for a menu and **required** for a listbox, and none
|
|
99
|
+
of the fleet's menus are long enough to need it — the largest has six items. Adding it means
|
|
100
|
+
a keystroke buffer and a timer in a component that has neither, for a menu you can cross in
|
|
101
|
+
three arrow presses.
|
|
102
|
+
|
|
103
|
+
<Canvas of={MenuStories.Interactive} />
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { placementArgType } from "../argTypes.storyHelpers.ts"
|
|
6
|
+
import { Button } from "../Button/Button.tsx"
|
|
7
|
+
import {
|
|
8
|
+
StoryCell,
|
|
9
|
+
StoryGrid,
|
|
10
|
+
} from "../board.storyHelpers.tsx"
|
|
11
|
+
import {
|
|
12
|
+
RedoIcon,
|
|
13
|
+
SettingsIcon,
|
|
14
|
+
UndoIcon,
|
|
15
|
+
} from "../icons.storyHelpers.tsx"
|
|
16
|
+
import type { MenuItem } from "./Menu.tsx"
|
|
17
|
+
import { Menu } from "./Menu.tsx"
|
|
18
|
+
|
|
19
|
+
const noop = () => undefined
|
|
20
|
+
|
|
21
|
+
const BAY_ACTIONS: MenuItem[] = [
|
|
22
|
+
{
|
|
23
|
+
icon: <UndoIcon />,
|
|
24
|
+
key: "retry",
|
|
25
|
+
label: "Retry title",
|
|
26
|
+
onSelect: noop,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
icon: <RedoIcon />,
|
|
30
|
+
key: "skip",
|
|
31
|
+
label: "Skip title",
|
|
32
|
+
onSelect: noop,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
icon: <SettingsIcon />,
|
|
36
|
+
isDisabled: true,
|
|
37
|
+
key: "eject",
|
|
38
|
+
label: "Eject disc",
|
|
39
|
+
onSelect: noop,
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A menu's visibility belongs to the app, exactly like `Popover`'s —
|
|
45
|
+
* so the stories hold it in a `useVisibility`, which is what a
|
|
46
|
+
* consumer writes too.
|
|
47
|
+
*/
|
|
48
|
+
const MenuHarness = ({
|
|
49
|
+
isInitiallyVisible = false,
|
|
50
|
+
items = BAY_ACTIONS,
|
|
51
|
+
triggerLabel,
|
|
52
|
+
}: {
|
|
53
|
+
isInitiallyVisible?: boolean
|
|
54
|
+
items?: MenuItem[]
|
|
55
|
+
triggerLabel: string
|
|
56
|
+
}): ReactNode => {
|
|
57
|
+
const { hide, isVisible, toggle } = useVisibility({
|
|
58
|
+
isVisible: isInitiallyVisible,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Menu
|
|
63
|
+
isVisible={isVisible}
|
|
64
|
+
items={items}
|
|
65
|
+
onDismiss={hide}
|
|
66
|
+
trigger={
|
|
67
|
+
<Button appearance="outline" onClick={toggle}>
|
|
68
|
+
{triggerLabel}
|
|
69
|
+
</Button>
|
|
70
|
+
}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const meta = {
|
|
76
|
+
title: "Components/Menu",
|
|
77
|
+
component: Menu,
|
|
78
|
+
parameters: { layout: "centered" },
|
|
79
|
+
argTypes: { placement: placementArgType },
|
|
80
|
+
args: {
|
|
81
|
+
items: BAY_ACTIONS,
|
|
82
|
+
placement: "bottom-start",
|
|
83
|
+
},
|
|
84
|
+
} satisfies Meta<typeof Menu>
|
|
85
|
+
|
|
86
|
+
export default meta
|
|
87
|
+
|
|
88
|
+
type Story = StoryObj<typeof meta>
|
|
89
|
+
|
|
90
|
+
export const Default: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
isVisible: false,
|
|
93
|
+
onDismiss: noop,
|
|
94
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
95
|
+
},
|
|
96
|
+
render: () => <MenuHarness triggerLabel="Actions" />,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A `menuitem` **does** something; an `option` **is** something. A
|
|
101
|
+
* screen reader announces "menu, 3 items" for one and "listbox,
|
|
102
|
+
* selected, 2 of 4" for the other, and mux-magic's `TypePicker`
|
|
103
|
+
* renders `role="menu"` over items that set a value — a listbox
|
|
104
|
+
* wearing the wrong role.
|
|
105
|
+
*/
|
|
106
|
+
export const AllVariants: Story = {
|
|
107
|
+
args: {
|
|
108
|
+
isVisible: false,
|
|
109
|
+
onDismiss: noop,
|
|
110
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
111
|
+
},
|
|
112
|
+
render: () => (
|
|
113
|
+
<StoryGrid columns={2}>
|
|
114
|
+
<StoryCell label="closed">
|
|
115
|
+
<MenuHarness triggerLabel="Bay 1" />
|
|
116
|
+
</StoryCell>
|
|
117
|
+
|
|
118
|
+
<StoryCell label="open">
|
|
119
|
+
<MenuHarness
|
|
120
|
+
isInitiallyVisible
|
|
121
|
+
triggerLabel="Bay 2"
|
|
122
|
+
/>
|
|
123
|
+
</StoryCell>
|
|
124
|
+
</StoryGrid>
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A disabled item is simply **not registered** with the roving
|
|
130
|
+
* group, so the arrow keys skip it without any command in
|
|
131
|
+
* `RovingFocus` knowing what "disabled" means. It stays in the DOM
|
|
132
|
+
* and stays announced — the difference between "not right now" and
|
|
133
|
+
* "does not exist".
|
|
134
|
+
*/
|
|
135
|
+
export const AllStates: Story = {
|
|
136
|
+
args: {
|
|
137
|
+
isVisible: false,
|
|
138
|
+
onDismiss: noop,
|
|
139
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
140
|
+
},
|
|
141
|
+
render: () => (
|
|
142
|
+
<MenuHarness isInitiallyVisible triggerLabel="Bay 3" />
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Open it and arrow down. Focus lands on the first item **on
|
|
148
|
+
* opening** — a menu that leaves the caret on the trigger is one a
|
|
149
|
+
* keyboard user cannot reach — and Escape closes it without the
|
|
150
|
+
* page moving.
|
|
151
|
+
*/
|
|
152
|
+
export const Interactive: Story = {
|
|
153
|
+
args: {
|
|
154
|
+
isVisible: false,
|
|
155
|
+
onDismiss: noop,
|
|
156
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
157
|
+
},
|
|
158
|
+
render: () => (
|
|
159
|
+
<MenuHarness triggerLabel="Open bay 4 menu" />
|
|
160
|
+
),
|
|
161
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
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 "./Menu.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, AllVariants, Interactive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("the menu is named, and its items are menuitems", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(AllStates)
|
|
16
|
+
|
|
17
|
+
// **Named by its trigger**, not by a `label` prop. `useRole` puts
|
|
18
|
+
// `aria-labelledby` on the panel pointing at the button, and
|
|
19
|
+
// `aria-labelledby` beats `aria-label` — so the `label` prop this
|
|
20
|
+
// component was first written with did nothing at all, silently.
|
|
21
|
+
//
|
|
22
|
+
// That narrows the M3/M4 lesson rather than contradicting it: an
|
|
23
|
+
// overlay with **no trigger relationship** (`Spinner`'s
|
|
24
|
+
// `role="status"`, `Popover`'s `role="dialog"`) needs its own
|
|
25
|
+
// name. A menu has one.
|
|
26
|
+
expectAgentDrivable(canvas, {
|
|
27
|
+
name: "Bay 3",
|
|
28
|
+
role: "menu",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expectAgentDrivable(canvas, {
|
|
32
|
+
name: "Retry title",
|
|
33
|
+
role: "menuitem",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await expectNoAxeViolations(canvasElement)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A menu is one of the few patterns where focus must jump on open —
|
|
41
|
+
* otherwise the keyboard user who pressed Enter on the trigger is
|
|
42
|
+
* still on the trigger, with a menu they cannot reach.
|
|
43
|
+
*/
|
|
44
|
+
test("opening moves focus into the menu", async () => {
|
|
45
|
+
const { canvas } = await mountStory(Interactive)
|
|
46
|
+
|
|
47
|
+
await userEvent.click(
|
|
48
|
+
expectAgentDrivable(canvas, {
|
|
49
|
+
name: "Open bay 4 menu",
|
|
50
|
+
role: "button",
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
await waitFor(() => {
|
|
55
|
+
expect(
|
|
56
|
+
expectAgentDrivable(canvas, {
|
|
57
|
+
name: "Retry title",
|
|
58
|
+
role: "menuitem",
|
|
59
|
+
}),
|
|
60
|
+
).toHaveFocus()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("the arrow keys move within the menu and skip the disabled item", async () => {
|
|
65
|
+
const { canvas } = await mountStory(AllStates)
|
|
66
|
+
|
|
67
|
+
const skip = expectAgentDrivable(canvas, {
|
|
68
|
+
name: "Skip title",
|
|
69
|
+
role: "menuitem",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const eject = expectAgentDrivable(canvas, {
|
|
73
|
+
name: "Eject disc",
|
|
74
|
+
role: "menuitem",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await expect(eject).toBeDisabled()
|
|
78
|
+
|
|
79
|
+
// Not a click to get started — **choosing dismisses the menu**, so
|
|
80
|
+
// clicking an item to "focus" it closes the thing under test. This
|
|
81
|
+
// story opens already visible, and opening is what puts focus on
|
|
82
|
+
// the first item.
|
|
83
|
+
const retry = expectAgentDrivable(canvas, {
|
|
84
|
+
name: "Retry title",
|
|
85
|
+
role: "menuitem",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(retry).toHaveFocus()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(skip).toHaveFocus()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Registration is membership: the disabled item never joined the
|
|
99
|
+
// roving group, so wrapping goes past it back to the first.
|
|
100
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(retry).toHaveFocus()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
await expect(eject).not.toHaveFocus()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Exactly one tab stop for the whole menu — the roving-tabindex
|
|
111
|
+
* rule, read straight from `selectTabIndex`. `expectAgentDrivable`
|
|
112
|
+
* asserts it for every composite member it is handed; this states it
|
|
113
|
+
* outright because a menu's members all carry `tabindex="-1"` and
|
|
114
|
+
* that is the pattern, not a bug.
|
|
115
|
+
*/
|
|
116
|
+
test("the menu has one tab stop", async () => {
|
|
117
|
+
const { canvas } = await mountStory(AllStates)
|
|
118
|
+
|
|
119
|
+
const menu = expectAgentDrivable(canvas, {
|
|
120
|
+
name: "Bay 3",
|
|
121
|
+
role: "menu",
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const tabbable = Array.from(
|
|
125
|
+
menu.querySelectorAll<HTMLButtonElement>(
|
|
126
|
+
'[role="menuitem"]',
|
|
127
|
+
),
|
|
128
|
+
).filter((one) => one.tabIndex === 0)
|
|
129
|
+
|
|
130
|
+
await expect(tabbable).toHaveLength(1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("choosing an item dismisses the menu", async () => {
|
|
134
|
+
const { canvas } = await mountStory(Interactive)
|
|
135
|
+
|
|
136
|
+
await userEvent.click(
|
|
137
|
+
expectAgentDrivable(canvas, {
|
|
138
|
+
name: "Open bay 4 menu",
|
|
139
|
+
role: "button",
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
await userEvent.click(
|
|
144
|
+
expectAgentDrivable(canvas, {
|
|
145
|
+
name: "Retry title",
|
|
146
|
+
role: "menuitem",
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// A menu that stays open after an action has fired is the one
|
|
151
|
+
// interaction users read as "it didn't work".
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(canvas.queryAllByRole("menuitem")).toHaveLength(
|
|
154
|
+
0,
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("Escape dismisses without choosing", async () => {
|
|
160
|
+
const { canvas } = await mountStory(Interactive)
|
|
161
|
+
|
|
162
|
+
await userEvent.click(
|
|
163
|
+
expectAgentDrivable(canvas, {
|
|
164
|
+
name: "Open bay 4 menu",
|
|
165
|
+
role: "button",
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(
|
|
171
|
+
canvas.queryAllByRole("menuitem").length,
|
|
172
|
+
).toBeGreaterThan(0)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
await userEvent.keyboard("{Escape}")
|
|
176
|
+
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(canvas.queryAllByRole("menuitem")).toHaveLength(
|
|
179
|
+
0,
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The panel is in the top layer via `popover="manual"` and **stays
|
|
186
|
+
* in the DOM where it was written** — so a scoped query still finds
|
|
187
|
+
* it. A portalled menu would move to `document.body` and every story
|
|
188
|
+
* here would have to reach for `screen`.
|
|
189
|
+
*/
|
|
190
|
+
test("the menu is not portalled out of its canvas", async () => {
|
|
191
|
+
const { canvas, canvasElement } =
|
|
192
|
+
await mountStory(AllVariants)
|
|
193
|
+
|
|
194
|
+
const menu = expectAgentDrivable(canvas, {
|
|
195
|
+
name: "Bay 2",
|
|
196
|
+
role: "menu",
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await expect(canvasElement).toContainElement(menu)
|
|
200
|
+
|
|
201
|
+
await expectNoAxeViolations(canvasElement)
|
|
202
|
+
})
|