@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.
Files changed (51) hide show
  1. package/dist/Combobox/Combobox.d.ts.map +1 -1
  2. package/dist/Combobox/Combobox.js +5 -1
  3. package/dist/Combobox/Combobox.js.map +1 -1
  4. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  5. package/dist/Combobox/ComboboxOption.js +1 -1
  6. package/dist/Combobox/ComboboxOption.js.map +1 -1
  7. package/dist/Overlay/useAnchoredOverlay.d.ts +9 -1
  8. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  9. package/dist/Overlay/useAnchoredOverlay.js +11 -5
  10. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  11. package/dist/Toolbar/Toolbar.d.ts +156 -0
  12. package/dist/Toolbar/Toolbar.d.ts.map +1 -0
  13. package/dist/Toolbar/Toolbar.js +169 -0
  14. package/dist/Toolbar/Toolbar.js.map +1 -0
  15. package/dist/Toolbar/ToolbarSlot.d.ts +44 -0
  16. package/dist/Toolbar/ToolbarSlot.d.ts.map +1 -0
  17. package/dist/Toolbar/ToolbarSlot.js +69 -0
  18. package/dist/Toolbar/ToolbarSlot.js.map +1 -0
  19. package/dist/Toolbar/chooseVisibleCount.d.ts +38 -0
  20. package/dist/Toolbar/chooseVisibleCount.d.ts.map +1 -0
  21. package/dist/Toolbar/chooseVisibleCount.js +69 -0
  22. package/dist/Toolbar/chooseVisibleCount.js.map +1 -0
  23. package/dist/Toolbar/useToolbarOverflow.d.ts +49 -0
  24. package/dist/Toolbar/useToolbarOverflow.d.ts.map +1 -0
  25. package/dist/Toolbar/useToolbarOverflow.js +157 -0
  26. package/dist/Toolbar/useToolbarOverflow.js.map +1 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/testing/expectAgentDrivable.d.ts.map +1 -1
  32. package/dist/testing/expectAgentDrivable.js +32 -7
  33. package/dist/testing/expectAgentDrivable.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/Combobox/Combobox.stories.tsx +80 -1
  36. package/src/Combobox/Combobox.test.tsx +55 -0
  37. package/src/Combobox/Combobox.tsx +5 -1
  38. package/src/Combobox/ComboboxOption.tsx +19 -8
  39. package/src/Overlay/useAnchoredOverlay.ts +31 -4
  40. package/src/Toolbar/Toolbar.mdx +126 -0
  41. package/src/Toolbar/Toolbar.stories.tsx +347 -0
  42. package/src/Toolbar/Toolbar.test.tsx +286 -0
  43. package/src/Toolbar/Toolbar.tsx +461 -0
  44. package/src/Toolbar/ToolbarSlot.tsx +122 -0
  45. package/src/Toolbar/chooseVisibleCount.test.ts +92 -0
  46. package/src/Toolbar/chooseVisibleCount.ts +92 -0
  47. package/src/Toolbar/useToolbarOverflow.ts +229 -0
  48. package/src/icons.storyHelpers.tsx +23 -0
  49. package/src/index.ts +8 -0
  50. package/src/sourceRules.test.ts +7 -1
  51. 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
+ })