@charcuterie/ui 2.10.2 → 2.12.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.map +1 -1
- package/dist/Combobox/Combobox.js +5 -1
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
- package/dist/Combobox/ComboboxOption.js +1 -1
- package/dist/Combobox/ComboboxOption.js.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts +9 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.js +11 -5
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
- package/dist/Toolbar/Toolbar.d.ts +156 -0
- package/dist/Toolbar/Toolbar.d.ts.map +1 -0
- package/dist/Toolbar/Toolbar.js +169 -0
- package/dist/Toolbar/Toolbar.js.map +1 -0
- package/dist/Toolbar/ToolbarSlot.d.ts +44 -0
- package/dist/Toolbar/ToolbarSlot.d.ts.map +1 -0
- package/dist/Toolbar/ToolbarSlot.js +69 -0
- package/dist/Toolbar/ToolbarSlot.js.map +1 -0
- package/dist/Toolbar/chooseVisibleCount.d.ts +38 -0
- package/dist/Toolbar/chooseVisibleCount.d.ts.map +1 -0
- package/dist/Toolbar/chooseVisibleCount.js +69 -0
- package/dist/Toolbar/chooseVisibleCount.js.map +1 -0
- package/dist/Toolbar/useToolbarOverflow.d.ts +49 -0
- package/dist/Toolbar/useToolbarOverflow.d.ts.map +1 -0
- package/dist/Toolbar/useToolbarOverflow.js +157 -0
- package/dist/Toolbar/useToolbarOverflow.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/testing/expectAgentDrivable.d.ts.map +1 -1
- package/dist/testing/expectAgentDrivable.js +32 -7
- package/dist/testing/expectAgentDrivable.js.map +1 -1
- package/package.json +2 -2
- package/src/Combobox/Combobox.stories.tsx +80 -1
- package/src/Combobox/Combobox.test.tsx +55 -0
- package/src/Combobox/Combobox.tsx +5 -1
- package/src/Combobox/ComboboxOption.tsx +19 -8
- package/src/Overlay/useAnchoredOverlay.ts +31 -4
- package/src/Toolbar/Toolbar.mdx +126 -0
- package/src/Toolbar/Toolbar.stories.tsx +347 -0
- package/src/Toolbar/Toolbar.test.tsx +286 -0
- package/src/Toolbar/Toolbar.tsx +461 -0
- package/src/Toolbar/ToolbarSlot.tsx +122 -0
- package/src/Toolbar/chooseVisibleCount.test.ts +92 -0
- package/src/Toolbar/chooseVisibleCount.ts +92 -0
- package/src/Toolbar/useToolbarOverflow.ts +229 -0
- package/src/icons.storyHelpers.tsx +23 -0
- package/src/index.ts +8 -0
- package/src/sourceRules.test.ts +7 -1
- package/src/testing/expectAgentDrivable.ts +50 -20
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { useMediaQuery } from "@charcuterie/logic"
|
|
2
|
+
import { matchMediaMatcher } from "@charcuterie/logic/browser"
|
|
3
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
import { useState } from "react"
|
|
6
|
+
|
|
7
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
8
|
+
import {
|
|
9
|
+
ContainerBoard,
|
|
10
|
+
StoryCell,
|
|
11
|
+
StoryGrid,
|
|
12
|
+
} from "../board.storyHelpers.tsx"
|
|
13
|
+
import { Header } from "../Header/Header.tsx"
|
|
14
|
+
import {
|
|
15
|
+
MoreIcon,
|
|
16
|
+
PlayIcon,
|
|
17
|
+
RedoIcon,
|
|
18
|
+
SettingsIcon,
|
|
19
|
+
UndoIcon,
|
|
20
|
+
} from "../icons.storyHelpers.tsx"
|
|
21
|
+
import { Main } from "../Main/Main.tsx"
|
|
22
|
+
import { Shell } from "../Shell/Shell.tsx"
|
|
23
|
+
import { Switch } from "../Switch/Switch.tsx"
|
|
24
|
+
import {
|
|
25
|
+
HeaderSchemeToggle,
|
|
26
|
+
PageContent,
|
|
27
|
+
} from "../shell.storyHelpers.tsx"
|
|
28
|
+
import type {
|
|
29
|
+
ToolbarAction,
|
|
30
|
+
ToolbarItem,
|
|
31
|
+
} from "./Toolbar.tsx"
|
|
32
|
+
import { Toolbar } from "./Toolbar.tsx"
|
|
33
|
+
|
|
34
|
+
const noop = () => undefined
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Priority order, highest first — `items[0]` is the last thing to
|
|
38
|
+
* collapse. rip-deck's bar, near enough: the two things you do to a
|
|
39
|
+
* running job, then the two you do to the deck.
|
|
40
|
+
*/
|
|
41
|
+
const DECK_ACTIONS: ToolbarAction[] = [
|
|
42
|
+
{
|
|
43
|
+
icon: <PlayIcon />,
|
|
44
|
+
key: "start",
|
|
45
|
+
label: "Start rip",
|
|
46
|
+
onSelect: noop,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
icon: <UndoIcon />,
|
|
50
|
+
key: "retry",
|
|
51
|
+
label: "Retry title",
|
|
52
|
+
onSelect: noop,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
icon: <RedoIcon />,
|
|
56
|
+
key: "skip",
|
|
57
|
+
label: "Skip title",
|
|
58
|
+
onSelect: noop,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
icon: <SettingsIcon />,
|
|
62
|
+
isDisabled: true,
|
|
63
|
+
key: "eject",
|
|
64
|
+
label: "Eject disc",
|
|
65
|
+
onSelect: noop,
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A dry-run switch with its own state, so the collapsed row is a
|
|
71
|
+
* **real** toggle rather than mux-magic's `aria-hidden` span — the
|
|
72
|
+
* defect the audit calls the worst in that file, because Dry Run
|
|
73
|
+
* announces identically on and off.
|
|
74
|
+
*/
|
|
75
|
+
const DryRunSwitch = (): ReactNode => {
|
|
76
|
+
const [isDryRun, setIsDryRun] = useState(true)
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Switch
|
|
80
|
+
isChecked={isDryRun}
|
|
81
|
+
label="Dry run"
|
|
82
|
+
onChange={setIsDryRun}
|
|
83
|
+
size="sm"
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The header's real mix: actions, a switch, and the scheme control.
|
|
90
|
+
* `role="menu"` would be invalid over this — hence `overflow="panel"`,
|
|
91
|
+
* which the type system requires here rather than suggesting.
|
|
92
|
+
*/
|
|
93
|
+
const MIXED_ITEMS: ToolbarItem[] = [
|
|
94
|
+
...DECK_ACTIONS.slice(0, 2),
|
|
95
|
+
{
|
|
96
|
+
element: <DryRunSwitch />,
|
|
97
|
+
key: "dry-run",
|
|
98
|
+
type: "control",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
element: <HeaderSchemeToggle />,
|
|
102
|
+
key: "scheme",
|
|
103
|
+
label: "Colour scheme",
|
|
104
|
+
type: "control",
|
|
105
|
+
},
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A fixed-width box, because the whole component is a measurement.
|
|
110
|
+
* A `layout: "centered"` story would size itself to its contents
|
|
111
|
+
* and never overflow at all.
|
|
112
|
+
*/
|
|
113
|
+
const Frame = ({
|
|
114
|
+
children,
|
|
115
|
+
inlineSize,
|
|
116
|
+
}: {
|
|
117
|
+
children: ReactNode
|
|
118
|
+
inlineSize: string
|
|
119
|
+
}): ReactNode => (
|
|
120
|
+
<div
|
|
121
|
+
className="flex rounded-md border border-border-subtle bg-surface-raised p-2"
|
|
122
|
+
style={{ inlineSize }}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const meta = {
|
|
129
|
+
title: "Components/Toolbar",
|
|
130
|
+
component: Toolbar,
|
|
131
|
+
parameters: { layout: "padded" },
|
|
132
|
+
argTypes: { size: controlSizeArgType },
|
|
133
|
+
args: {
|
|
134
|
+
items: DECK_ACTIONS,
|
|
135
|
+
label: "Deck actions",
|
|
136
|
+
overflow: "menu",
|
|
137
|
+
overflowIcon: <MoreIcon />,
|
|
138
|
+
overflowLabel: "More actions",
|
|
139
|
+
size: "sm",
|
|
140
|
+
},
|
|
141
|
+
} satisfies Meta<typeof Toolbar>
|
|
142
|
+
|
|
143
|
+
export default meta
|
|
144
|
+
|
|
145
|
+
type Story = StoryObj<typeof meta>
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wide enough for everything, so there is **no overflow trigger at
|
|
149
|
+
* all**. mux-magic shows its ⋮ at every width, which is why its
|
|
150
|
+
* collapse is not progressive: almost everything lives in the
|
|
151
|
+
* popover permanently.
|
|
152
|
+
*/
|
|
153
|
+
export const Default: Story = {
|
|
154
|
+
render: (toolbarProps) => (
|
|
155
|
+
<Frame inlineSize="34rem">
|
|
156
|
+
<Toolbar {...toolbarProps} />
|
|
157
|
+
</Frame>
|
|
158
|
+
),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The two overflow kinds, and the distinction is a **type**.
|
|
163
|
+
*
|
|
164
|
+
* `overflow="menu"` narrows `items` to actions only and opens a real
|
|
165
|
+
* `role="menu"`. `overflow="panel"` accepts controls too and opens a
|
|
166
|
+
* `Popover` (`role="dialog"`, `aria-haspopup="dialog"`), because
|
|
167
|
+
* `role="menu"` permits only `menuitem`-family children — putting a
|
|
168
|
+
* scheme switcher in one, as plex-channels does today, is invalid
|
|
169
|
+
* ARIA that no gate catches.
|
|
170
|
+
*/
|
|
171
|
+
export const AllVariants: Story = {
|
|
172
|
+
render: ({ size }) => (
|
|
173
|
+
<StoryGrid columns={1}>
|
|
174
|
+
<StoryCell
|
|
175
|
+
label={`overflow="menu" — every row is an action`}
|
|
176
|
+
>
|
|
177
|
+
<Frame inlineSize="9rem">
|
|
178
|
+
<Toolbar
|
|
179
|
+
items={DECK_ACTIONS}
|
|
180
|
+
label="Deck actions"
|
|
181
|
+
overflow="menu"
|
|
182
|
+
overflowIcon={<MoreIcon />}
|
|
183
|
+
size={size}
|
|
184
|
+
/>
|
|
185
|
+
</Frame>
|
|
186
|
+
</StoryCell>
|
|
187
|
+
|
|
188
|
+
<StoryCell label={`overflow="panel" — mixed content`}>
|
|
189
|
+
<Frame inlineSize="14rem">
|
|
190
|
+
<Toolbar
|
|
191
|
+
items={MIXED_ITEMS}
|
|
192
|
+
label="Deck controls"
|
|
193
|
+
overflow="panel"
|
|
194
|
+
overflowIcon={<MoreIcon />}
|
|
195
|
+
overflowLabel="More controls"
|
|
196
|
+
size={size}
|
|
197
|
+
/>
|
|
198
|
+
</Frame>
|
|
199
|
+
</StoryCell>
|
|
200
|
+
</StoryGrid>
|
|
201
|
+
),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Nothing collapsed, something collapsed, and the overflow open —
|
|
206
|
+
* the three states the audit says nobody screenshots. The open ones
|
|
207
|
+
* are the point: an overflow audited only while shut is an overflow
|
|
208
|
+
* nobody audited.
|
|
209
|
+
*/
|
|
210
|
+
export const AllStates: Story = {
|
|
211
|
+
render: ({ size }) => (
|
|
212
|
+
<StoryGrid columns={1}>
|
|
213
|
+
<StoryCell label="34rem — nothing collapsed">
|
|
214
|
+
<Frame inlineSize="34rem">
|
|
215
|
+
<Toolbar
|
|
216
|
+
items={DECK_ACTIONS}
|
|
217
|
+
label="Deck actions, wide"
|
|
218
|
+
overflow="menu"
|
|
219
|
+
overflowIcon={<MoreIcon />}
|
|
220
|
+
size={size}
|
|
221
|
+
/>
|
|
222
|
+
</Frame>
|
|
223
|
+
</StoryCell>
|
|
224
|
+
|
|
225
|
+
<StoryCell label="9rem — collapsed, menu open">
|
|
226
|
+
<Frame inlineSize="9rem">
|
|
227
|
+
<Toolbar
|
|
228
|
+
isOverflowVisible
|
|
229
|
+
items={DECK_ACTIONS}
|
|
230
|
+
label="Deck actions, narrow"
|
|
231
|
+
overflow="menu"
|
|
232
|
+
overflowIcon={<MoreIcon />}
|
|
233
|
+
size={size}
|
|
234
|
+
/>
|
|
235
|
+
</Frame>
|
|
236
|
+
</StoryCell>
|
|
237
|
+
|
|
238
|
+
<StoryCell label="12rem — collapsed, mixed panel open">
|
|
239
|
+
<Frame inlineSize="12rem">
|
|
240
|
+
<Toolbar
|
|
241
|
+
isOverflowVisible
|
|
242
|
+
items={MIXED_ITEMS}
|
|
243
|
+
label="Deck controls, narrow"
|
|
244
|
+
overflow="panel"
|
|
245
|
+
overflowIcon={<MoreIcon />}
|
|
246
|
+
overflowLabel="More controls"
|
|
247
|
+
size={size}
|
|
248
|
+
/>
|
|
249
|
+
</Frame>
|
|
250
|
+
</StoryCell>
|
|
251
|
+
</StoryGrid>
|
|
252
|
+
),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Progressive collapse, measured rather than breakpointed. Same
|
|
257
|
+
* items, three container widths, and the count in the bar falls one
|
|
258
|
+
* at a time from the **end** of the priority order — no `480px`
|
|
259
|
+
* anywhere, so a longer label or a denser theme moves the collapse
|
|
260
|
+
* point on its own.
|
|
261
|
+
*/
|
|
262
|
+
export const Responsive: Story = {
|
|
263
|
+
render: ({ size }) => (
|
|
264
|
+
<ContainerBoard>
|
|
265
|
+
{(width) => (
|
|
266
|
+
<Frame inlineSize="100%">
|
|
267
|
+
<Toolbar
|
|
268
|
+
items={DECK_ACTIONS}
|
|
269
|
+
label={`Deck actions at ${width}`}
|
|
270
|
+
overflow="menu"
|
|
271
|
+
overflowIcon={<MoreIcon />}
|
|
272
|
+
size={size}
|
|
273
|
+
/>
|
|
274
|
+
</Frame>
|
|
275
|
+
)}
|
|
276
|
+
</ContainerBoard>
|
|
277
|
+
),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Tab **once** to enter, then the arrow keys move along the row and
|
|
282
|
+
* Home/End jump to its ends — one tab stop for the whole toolbar,
|
|
283
|
+
* which is what the APG asks for and what four repos' `role="toolbar"`
|
|
284
|
+
* does not do. Open the overflow and focus moves into it; Escape
|
|
285
|
+
* closes it and hands focus back to the trigger.
|
|
286
|
+
*/
|
|
287
|
+
export const Interactive: Story = {
|
|
288
|
+
render: ({ size }) => (
|
|
289
|
+
<Frame inlineSize="9rem">
|
|
290
|
+
<Toolbar
|
|
291
|
+
items={DECK_ACTIONS}
|
|
292
|
+
label="Deck actions, keyboard"
|
|
293
|
+
overflow="menu"
|
|
294
|
+
overflowIcon={<MoreIcon />}
|
|
295
|
+
size={size}
|
|
296
|
+
/>
|
|
297
|
+
</Frame>
|
|
298
|
+
),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Where it actually goes: `Shell` → `Header`, in the header's own
|
|
303
|
+
* row, with the scheme control among its items rather than beside
|
|
304
|
+
* them.
|
|
305
|
+
*
|
|
306
|
+
* The header is also where the **other** half of the fleet's problem
|
|
307
|
+
* lives — on a phone the bar wants to leave the header entirely, and
|
|
308
|
+
* that is a relocation, not a fit. So this story composes the two:
|
|
309
|
+
* `useMediaQuery` (from `@charcuterie/logic`, over the `matchMedia`
|
|
310
|
+
* matcher) decides *where* the toolbar is mounted, and `Toolbar`
|
|
311
|
+
* decides *what fits* once it is there. Exactly one instance either
|
|
312
|
+
* way — the element moves, it is never rendered twice and hidden.
|
|
313
|
+
*/
|
|
314
|
+
const AppShellHarness = (): ReactNode => {
|
|
315
|
+
const { isMatching: isNarrow } = useMediaQuery({
|
|
316
|
+
matcher: matchMediaMatcher("(width < 40rem)"),
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const toolbar = (
|
|
320
|
+
<Toolbar
|
|
321
|
+
items={MIXED_ITEMS}
|
|
322
|
+
label="Deck controls"
|
|
323
|
+
overflow="panel"
|
|
324
|
+
overflowIcon={<MoreIcon />}
|
|
325
|
+
overflowLabel="More controls"
|
|
326
|
+
/>
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<Shell>
|
|
331
|
+
<Header heading="Rip Deck">
|
|
332
|
+
{isNarrow ? null : toolbar}
|
|
333
|
+
</Header>
|
|
334
|
+
|
|
335
|
+
<Main>
|
|
336
|
+
{isNarrow ? toolbar : null}
|
|
337
|
+
|
|
338
|
+
<PageContent />
|
|
339
|
+
</Main>
|
|
340
|
+
</Shell>
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export const InAppShell: Story = {
|
|
345
|
+
parameters: { layout: "fullscreen" },
|
|
346
|
+
render: () => <AppShellHarness />,
|
|
347
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import {
|
|
3
|
+
expect,
|
|
4
|
+
userEvent,
|
|
5
|
+
waitFor,
|
|
6
|
+
within,
|
|
7
|
+
} from "storybook/test"
|
|
8
|
+
import { test } from "vitest"
|
|
9
|
+
|
|
10
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
11
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
12
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
13
|
+
import * as stories from "./Toolbar.stories.tsx"
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
AllStates,
|
|
17
|
+
AllVariants,
|
|
18
|
+
Default,
|
|
19
|
+
InAppShell,
|
|
20
|
+
Interactive,
|
|
21
|
+
} = composeStories(stories)
|
|
22
|
+
|
|
23
|
+
test("the toolbar is named, and its actions are buttons", async () => {
|
|
24
|
+
const { canvas } = await mountStory(Default)
|
|
25
|
+
|
|
26
|
+
const toolbar = expectAgentDrivable(canvas, {
|
|
27
|
+
name: "Deck actions",
|
|
28
|
+
role: "toolbar",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expectAgentDrivable(canvas, {
|
|
32
|
+
name: "Start rip",
|
|
33
|
+
role: "button",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await expectNoAxeViolations(toolbar)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The trigger exists **only when something overflowed**, which is
|
|
41
|
+
* the difference between progressive collapse and mux-magic's ⋮ —
|
|
42
|
+
* displayed at every width, so almost everything lives in the
|
|
43
|
+
* popover permanently.
|
|
44
|
+
*/
|
|
45
|
+
test("a bar with room shows no overflow trigger", async () => {
|
|
46
|
+
const { canvas } = await mountStory(Default)
|
|
47
|
+
|
|
48
|
+
const toolbar = expectAgentDrivable(canvas, {
|
|
49
|
+
name: "Deck actions",
|
|
50
|
+
role: "toolbar",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await waitFor(() => {
|
|
54
|
+
expect(
|
|
55
|
+
within(toolbar).queryByRole("button", {
|
|
56
|
+
name: "More actions",
|
|
57
|
+
}),
|
|
58
|
+
).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// All four still in the row — the disabled one included, because
|
|
62
|
+
// "not right now" is not "does not exist".
|
|
63
|
+
await expect(
|
|
64
|
+
within(toolbar).getAllByRole("button"),
|
|
65
|
+
).toHaveLength(4)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Exactly one tab stop for the whole toolbar — the APG's
|
|
70
|
+
* requirement, and the thing four repos' `role="toolbar"` does not
|
|
71
|
+
* do. Read from `selectTabIndex` rather than restated, and written
|
|
72
|
+
* onto whatever focusable the slot holds, because a `control` is
|
|
73
|
+
* the app's own component and most of them forward no `tabIndex`.
|
|
74
|
+
*/
|
|
75
|
+
test("the toolbar is one tab stop", async () => {
|
|
76
|
+
const { canvas } = await mountStory(Default)
|
|
77
|
+
|
|
78
|
+
const toolbar = expectAgentDrivable(canvas, {
|
|
79
|
+
name: "Deck actions",
|
|
80
|
+
role: "toolbar",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
await waitFor(() => {
|
|
84
|
+
expect(
|
|
85
|
+
within(toolbar)
|
|
86
|
+
.getAllByRole("button")
|
|
87
|
+
// A disabled button is out of the tab order whatever its
|
|
88
|
+
// `tabIndex` property says, and it never registered with
|
|
89
|
+
// the roving group either — the same mechanism that makes
|
|
90
|
+
// the arrow keys skip it.
|
|
91
|
+
.filter(
|
|
92
|
+
(button) =>
|
|
93
|
+
!(button as HTMLButtonElement).disabled &&
|
|
94
|
+
button.tabIndex === 0,
|
|
95
|
+
),
|
|
96
|
+
).toHaveLength(1)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("the arrow keys move along the row", async () => {
|
|
101
|
+
const { canvas } = await mountStory(Default)
|
|
102
|
+
|
|
103
|
+
const start = expectAgentDrivable(canvas, {
|
|
104
|
+
name: "Start rip",
|
|
105
|
+
role: "button",
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const retry = expectAgentDrivable(canvas, {
|
|
109
|
+
name: "Retry title",
|
|
110
|
+
role: "button",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
start.focus()
|
|
114
|
+
|
|
115
|
+
await userEvent.keyboard("{ArrowRight}")
|
|
116
|
+
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(retry).toHaveFocus()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await userEvent.keyboard("{Home}")
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(start).toHaveFocus()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The defect this component exists to delete: mux-magic renders its
|
|
130
|
+
* whole control set twice and hides one copy with a CSS specificity
|
|
131
|
+
* coin-flip, so every action is in the DOM at every viewport and an
|
|
132
|
+
* agent driving the page sees phantom controls.
|
|
133
|
+
*/
|
|
134
|
+
test("a collapsed action is mounted exactly once", async () => {
|
|
135
|
+
const { body } = await mountStory(AllStates)
|
|
136
|
+
|
|
137
|
+
const narrowToolbar = expectAgentDrivable(body, {
|
|
138
|
+
name: "Deck actions, narrow",
|
|
139
|
+
role: "toolbar",
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// It overflowed, so the trigger is there.
|
|
143
|
+
expectAgentDrivable(within(narrowToolbar), {
|
|
144
|
+
name: "More actions",
|
|
145
|
+
role: "button",
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// …and the collapsed action is in the open menu, once, in the
|
|
149
|
+
// whole document — not once in the bar and once in the panel.
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(
|
|
152
|
+
body.getAllByRole("menuitem", {
|
|
153
|
+
name: "Skip title",
|
|
154
|
+
}),
|
|
155
|
+
).toHaveLength(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
await expect(
|
|
159
|
+
within(narrowToolbar).queryByRole("button", {
|
|
160
|
+
name: "Skip title",
|
|
161
|
+
}),
|
|
162
|
+
).toBeNull()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A trigger that advertises what it does. mux-magic's file contains
|
|
167
|
+
* zero `aria-expanded`, `aria-haspopup` or `aria-controls` — here
|
|
168
|
+
* they come from `useRole` inside `Menu`/`Popover`, so they cannot
|
|
169
|
+
* be forgotten.
|
|
170
|
+
*/
|
|
171
|
+
test("the trigger advertises the overlay it opens", async () => {
|
|
172
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
173
|
+
|
|
174
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
175
|
+
name: "More actions",
|
|
176
|
+
role: "button",
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
await expect(trigger).toHaveAttribute(
|
|
180
|
+
"aria-expanded",
|
|
181
|
+
"false",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
await expect(trigger).toHaveAttribute(
|
|
185
|
+
"aria-haspopup",
|
|
186
|
+
"menu",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
await userEvent.click(trigger)
|
|
190
|
+
|
|
191
|
+
await expect(trigger).toHaveAttribute(
|
|
192
|
+
"aria-expanded",
|
|
193
|
+
"true",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
// Focus moves into the menu on opening — `Menu` owns that — so
|
|
197
|
+
// the keyboard user who pressed Enter on the trigger is not left
|
|
198
|
+
// on a menu they cannot reach.
|
|
199
|
+
const menu = expectAgentDrivable(body, {
|
|
200
|
+
name: "More actions",
|
|
201
|
+
role: "menu",
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
expect(menu).toContainElement(
|
|
206
|
+
document.activeElement as HTMLElement,
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
await userEvent.keyboard("{Escape}")
|
|
211
|
+
|
|
212
|
+
// …and back to the trigger on close.
|
|
213
|
+
await waitFor(() => {
|
|
214
|
+
expect(trigger).toHaveFocus()
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `role="menu"` permits only the `menuitem` family, so a scheme
|
|
220
|
+
* switcher inside one — which plex-channels ships — is invalid
|
|
221
|
+
* ARIA. Mixed content gets a `Popover`: `role="dialog"`,
|
|
222
|
+
* `aria-haspopup="dialog"`, and a switch is legal content.
|
|
223
|
+
*/
|
|
224
|
+
test("a mixed overflow is a dialog, not a menu", async () => {
|
|
225
|
+
const { body } = await mountStory(AllStates)
|
|
226
|
+
|
|
227
|
+
const panel = expectAgentDrivable(body, {
|
|
228
|
+
name: "More controls",
|
|
229
|
+
role: "dialog",
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// A real toggle with real state, against mux-magic's
|
|
233
|
+
// `aria-hidden` span: Dry Run announces identically on and off
|
|
234
|
+
// there, and is the worst defect in that file.
|
|
235
|
+
const dryRun = expectAgentDrivable(within(panel), {
|
|
236
|
+
name: "Dry run",
|
|
237
|
+
role: "switch",
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
await expect(dryRun).toHaveAttribute(
|
|
241
|
+
"aria-checked",
|
|
242
|
+
"true",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
await expectNoAxeViolations(panel)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test("both overflow kinds audit clean while open", async () => {
|
|
249
|
+
const { canvasElement } = await mountStory(AllStates)
|
|
250
|
+
|
|
251
|
+
// The resting states, and then the driven ones — an overflow
|
|
252
|
+
// audited only while shut is an overflow nobody audited.
|
|
253
|
+
await expectNoAxeViolations(canvasElement)
|
|
254
|
+
|
|
255
|
+
await expectNoAxeViolations(document.body)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test("the two overflow kinds render side by side", async () => {
|
|
259
|
+
const { canvas } = await mountStory(AllVariants)
|
|
260
|
+
|
|
261
|
+
expectAgentDrivable(canvas, {
|
|
262
|
+
name: "Deck actions",
|
|
263
|
+
role: "toolbar",
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
expectAgentDrivable(canvas, {
|
|
267
|
+
name: "Deck controls",
|
|
268
|
+
role: "toolbar",
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Where it actually goes. `useMediaQuery` decides *where* the bar is
|
|
274
|
+
* mounted and `Toolbar` decides *what fits* once it is there —
|
|
275
|
+
* plex-channels' relocation pattern and progressive collapse are
|
|
276
|
+
* different jobs, and both are needed.
|
|
277
|
+
*/
|
|
278
|
+
test("the shell mounts exactly one toolbar", async () => {
|
|
279
|
+
const { canvas } = await mountStory(InAppShell)
|
|
280
|
+
|
|
281
|
+
await expect(
|
|
282
|
+
canvas.getAllByRole("toolbar", {
|
|
283
|
+
name: "Deck controls",
|
|
284
|
+
}),
|
|
285
|
+
).toHaveLength(1)
|
|
286
|
+
})
|