@charcuterie/ui 3.31.0 → 3.33.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 (44) hide show
  1. package/README.md +14 -0
  2. package/dist/ActionTiles/ActionTiles.d.ts +115 -0
  3. package/dist/ActionTiles/ActionTiles.d.ts.map +1 -0
  4. package/dist/ActionTiles/ActionTiles.js +115 -0
  5. package/dist/ActionTiles/ActionTiles.js.map +1 -0
  6. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
  7. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
  8. package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
  9. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
  10. package/dist/RadioGroup/RadioGroup.d.ts +10 -0
  11. package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
  12. package/dist/RadioGroup/RadioGroup.js +12 -39
  13. package/dist/RadioGroup/RadioGroup.js.map +1 -1
  14. package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
  15. package/dist/RadioGroup/RadioGroupOption.js +3 -33
  16. package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
  17. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
  18. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  19. package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
  20. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/tileStyles.d.ts +83 -0
  26. package/dist/tileStyles.d.ts.map +1 -0
  27. package/dist/tileStyles.js +98 -0
  28. package/dist/tileStyles.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/ActionTiles/ActionTiles.mdx +131 -0
  31. package/src/ActionTiles/ActionTiles.stories.tsx +311 -0
  32. package/src/ActionTiles/ActionTiles.test.tsx +292 -0
  33. package/src/ActionTiles/ActionTiles.tsx +292 -0
  34. package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
  35. package/src/RadioGroup/RadioGroup.mdx +7 -0
  36. package/src/RadioGroup/RadioGroup.tsx +16 -42
  37. package/src/RadioGroup/RadioGroupOption.tsx +8 -38
  38. package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
  39. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
  40. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
  41. package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
  42. package/src/index.ts +5 -0
  43. package/src/sourceRules.test.ts +9 -1
  44. package/src/tileStyles.ts +118 -0
@@ -0,0 +1,311 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+ import {
5
+ ContainerBoard,
6
+ StoryCell,
7
+ StoryGrid,
8
+ StorySection,
9
+ } from "../board.storyHelpers.tsx"
10
+ import {
11
+ InboxIcon,
12
+ PlayIcon,
13
+ SearchIcon,
14
+ } from "../icons.storyHelpers.tsx"
15
+ import { RadioGroup } from "../RadioGroup/RadioGroup.tsx"
16
+ import type { ActionTileItem } from "./ActionTiles.tsx"
17
+ import { ActionTiles } from "./ActionTiles.tsx"
18
+
19
+ /**
20
+ * The shape this component was built for: a first step that opens
21
+ * the next step. Nothing stays selected, because nothing was chosen
22
+ * — the page moved on.
23
+ */
24
+ const QUEUE_TYPE_ITEMS: ActionTileItem[] = [
25
+ {
26
+ hint: "Choose titles yourself, then arrange them in priority and random lanes.",
27
+ label: "Picks",
28
+ value: "picks",
29
+ },
30
+ {
31
+ hint: "Set eligibility filters and let the app select matching titles.",
32
+ label: "Rules",
33
+ value: "rules",
34
+ },
35
+ ]
36
+
37
+ /** A tile may lead with an icon. The library ships none. */
38
+ const START_ITEMS: ActionTileItem[] = [
39
+ {
40
+ hint: "Everything that arrived since the last run.",
41
+ icon: <InboxIcon />,
42
+ label: "New arrivals",
43
+ value: "new",
44
+ },
45
+ {
46
+ hint: "Whatever the saved query matches today.",
47
+ icon: <SearchIcon />,
48
+ label: "Saved search",
49
+ value: "search",
50
+ },
51
+ {
52
+ hint: "Pick up where the last session stopped.",
53
+ icon: <PlayIcon />,
54
+ label: "Continue",
55
+ value: "continue",
56
+ },
57
+ ]
58
+
59
+ /**
60
+ * A tile that carries an `href` is a real `<a href>`, so
61
+ * middle-click, ctrl-click and "copy link address" all work. The
62
+ * same set may mix links and buttons.
63
+ */
64
+ const TOOL_ITEMS: ActionTileItem[] = [
65
+ {
66
+ hint: "Read a container's tracks and say what would change.",
67
+ href: "/tools/inspect",
68
+ label: "Inspect",
69
+ value: "inspect",
70
+ },
71
+ {
72
+ hint: "Rewrite the container without touching a stream.",
73
+ href: "/tools/remux",
74
+ label: "Remux",
75
+ value: "remux",
76
+ },
77
+ {
78
+ hint: "The reference, on the upstream site.",
79
+ href: "https://example.com/docs",
80
+ isExternal: true,
81
+ label: "Read the docs",
82
+ value: "docs",
83
+ },
84
+ ]
85
+
86
+ const meta = {
87
+ title: "Components/Actions/ActionTiles",
88
+ component: ActionTiles,
89
+ parameters: { layout: "padded" },
90
+ argTypes: { size: controlSizeArgType },
91
+ // The component's own defaults, restated — Storybook does not seed
92
+ // `args` from docgen, so an unstated default shows in the props
93
+ // table with nothing selected in its control.
94
+ args: {
95
+ minTileInlineSize: 200,
96
+ size: "md",
97
+ },
98
+ } satisfies Meta<typeof ActionTiles>
99
+
100
+ export default meta
101
+
102
+ type Story = StoryObj<typeof meta>
103
+
104
+ export const Default: Story = {
105
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
106
+ }
107
+
108
+ export const AllVariants: Story = {
109
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
110
+ render: (controlProps) => (
111
+ <StorySection title="Three sizes, all reading the density axis rather than hardcoding a length — the same three a RadioGroup tile has, from the same tokens.">
112
+ <StoryGrid columns={3}>
113
+ {(["sm", "md", "lg"] as const).map((size) => (
114
+ <StoryCell
115
+ align="stretch"
116
+ key={size}
117
+ label={size}
118
+ >
119
+ <ActionTiles
120
+ {...controlProps}
121
+ label={`Queue type at ${size}`}
122
+ minTileInlineSize={160}
123
+ size={size}
124
+ />
125
+ </StoryCell>
126
+ ))}
127
+ </StoryGrid>
128
+ </StorySection>
129
+ ),
130
+ }
131
+
132
+ /**
133
+ * A tile may lead with an icon. The library ships none — these are
134
+ * story-only SVGs, exactly what an app passes — and the icon is
135
+ * `aria-hidden` by construction, because the name it sits above is
136
+ * inside the same tile.
137
+ */
138
+ export const WithIcons: Story = {
139
+ args: { items: START_ITEMS, label: "Where to start" },
140
+ }
141
+
142
+ /**
143
+ * `href` makes the tile an anchor rather than a button, routed
144
+ * through the injected `RouterLink` when the destination is the
145
+ * router's. An `isExternal` tile falls back to a plain `<a
146
+ * target="_blank">` and says so to a screen reader.
147
+ */
148
+ export const Links: Story = {
149
+ args: { items: TOOL_ITEMS, label: "Pick a tool" },
150
+ }
151
+
152
+ export const AllStates: Story = {
153
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
154
+ render: (controlProps) => (
155
+ <StorySection title="What varies is what a tile does and whether it can be pressed. Nothing here is 'selected' — that state belongs to RadioGroup itemShape='tile'.">
156
+ <StoryGrid columns={2}>
157
+ <StoryCell align="stretch" label="two actions">
158
+ <ActionTiles
159
+ {...controlProps}
160
+ label="Queue type"
161
+ />
162
+ </StoryCell>
163
+
164
+ <StoryCell align="stretch" label="no hint">
165
+ <ActionTiles
166
+ {...controlProps}
167
+ items={[
168
+ { label: "Picks", value: "picks" },
169
+ { label: "Rules", value: "rules" },
170
+ ]}
171
+ label="Queue type, names only"
172
+ />
173
+ </StoryCell>
174
+
175
+ <StoryCell align="stretch" label="a disabled tile">
176
+ <ActionTiles
177
+ {...controlProps}
178
+ items={[
179
+ QUEUE_TYPE_ITEMS[0] as ActionTileItem,
180
+ {
181
+ hint: "No provider is configured to filter on.",
182
+ isDisabled: true,
183
+ label: "Rules",
184
+ value: "rules",
185
+ },
186
+ ]}
187
+ label="Queue type with Rules unavailable"
188
+ />
189
+ </StoryCell>
190
+
191
+ <StoryCell align="stretch" label="a disabled link">
192
+ <ActionTiles
193
+ {...controlProps}
194
+ items={[
195
+ {
196
+ hint: "Read a container's tracks and say what would change.",
197
+ href: "/tools/inspect",
198
+ label: "Inspect",
199
+ value: "inspect",
200
+ },
201
+ {
202
+ hint: "Nothing is loaded to remux yet.",
203
+ href: "/tools/remux",
204
+ isDisabled: true,
205
+ label: "Remux",
206
+ value: "remux",
207
+ },
208
+ ]}
209
+ label="Pick a tool with Remux unavailable"
210
+ />
211
+ </StoryCell>
212
+
213
+ <StoryCell align="stretch" label="links">
214
+ <ActionTiles
215
+ {...controlProps}
216
+ items={TOOL_ITEMS}
217
+ label="Pick a tool"
218
+ />
219
+ </StoryCell>
220
+
221
+ <StoryCell align="stretch" label="icons">
222
+ <ActionTiles
223
+ {...controlProps}
224
+ items={START_ITEMS}
225
+ label="Where to start"
226
+ />
227
+ </StoryCell>
228
+ </StoryGrid>
229
+ </StorySection>
230
+ ),
231
+ }
232
+
233
+ /**
234
+ * The two tile components, side by side, which is the comparison a
235
+ * reader choosing between them actually needs.
236
+ *
237
+ * The box is the **same box** — `tileStyles.ts` holds the padding,
238
+ * the border, the radius, the surface, the type ramp and the grid,
239
+ * and both components read it. What differs is everything the box is
240
+ * not: one announces "1 of 2" and keeps a tile selected, the other
241
+ * presses and is done.
242
+ */
243
+ export const BesideTheRadioTile: Story = {
244
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
245
+ render: (controlProps) => (
246
+ <StorySection title="One box, two controls. Only the resting radio tile matches — a checked one wears the accent edge, which is the state ActionTiles has none of.">
247
+ <StoryGrid columns={2}>
248
+ <StoryCell
249
+ align="stretch"
250
+ label="ActionTiles — presses and is done"
251
+ >
252
+ <ActionTiles {...controlProps} />
253
+ </StoryCell>
254
+
255
+ <StoryCell
256
+ align="stretch"
257
+ label="RadioGroup itemShape='tile' — holds a value"
258
+ >
259
+ <RadioGroup
260
+ itemShape="tile"
261
+ items={QUEUE_TYPE_ITEMS.map(
262
+ ({ hint, label, value }) => ({
263
+ hint,
264
+ label,
265
+ value,
266
+ }),
267
+ )}
268
+ label="Queue type, as radios"
269
+ size={controlProps.size}
270
+ />
271
+ </StoryCell>
272
+ </StoryGrid>
273
+ </StorySection>
274
+ ),
275
+ }
276
+
277
+ /**
278
+ * Column count comes from the **container**, never the window — the
279
+ * standing rule for any list of cards, and the reason this board
280
+ * varies a wrapper's width rather than the viewport's.
281
+ *
282
+ * At the 200px default floor: one track at 15rem, one at 24rem, two
283
+ * at 34rem. `auto-fill` and deliberately not `auto-fit`, so the
284
+ * tiles keep their size in a container with room to spare instead of
285
+ * stretching to fill it. Same formula as the `RadioGroup` tile, from
286
+ * the same constant.
287
+ */
288
+ export const Responsive: Story = {
289
+ args: { items: START_ITEMS, label: "Where to start" },
290
+ render: (controlProps) => (
291
+ <ContainerBoard>
292
+ {(width) => (
293
+ <ActionTiles
294
+ {...controlProps}
295
+ label={`Where to start at ${width}`}
296
+ />
297
+ )}
298
+ </ContainerBoard>
299
+ ),
300
+ }
301
+
302
+ /**
303
+ * The complete keyboard path, and it is the platform's: Tab reaches
304
+ * **each** tile, Enter and Space press a button one, Enter follows a
305
+ * link one. There is no roving tabindex here — a radio group borrows
306
+ * one tab stop because exactly one of its options is true at a time,
307
+ * and nothing here is true.
308
+ */
309
+ export const Interactive: Story = {
310
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
311
+ }
@@ -0,0 +1,292 @@
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 "./ActionTiles.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ BesideTheRadioTile,
13
+ Default,
14
+ Interactive,
15
+ Links,
16
+ Responsive,
17
+ WithIcons,
18
+ } = composeStories(stories)
19
+
20
+ test("it is a named group of pressable tiles", async () => {
21
+ const { canvas, canvasElement } =
22
+ await mountStory(Default)
23
+
24
+ const group = expectAgentDrivable(canvas, {
25
+ name: "Queue type",
26
+ role: "group",
27
+ })
28
+
29
+ await expect(
30
+ group.querySelectorAll("button"),
31
+ ).toHaveLength(2)
32
+
33
+ // Nothing here is selected, and nothing claims to be. The whole
34
+ // reason this is not a `RadioGroup` in a costume.
35
+ await expect(
36
+ group.querySelector(
37
+ "[aria-checked], [aria-pressed], [role='radio']",
38
+ ),
39
+ ).toBeNull()
40
+
41
+ await expectNoAxeViolations(canvasElement)
42
+ })
43
+
44
+ test("a tile's hint is part of its accessible name", async () => {
45
+ const { canvas } = await mountStory(Default)
46
+
47
+ // The reason the hint is rendered inside the tile rather than
48
+ // beside it: a hint a screen reader never reads is a hint half the
49
+ // audience does not have. `getByRole` computes the name from
50
+ // contents, so this query only passes if it is in there.
51
+ expectAgentDrivable(canvas, {
52
+ name: "Picks Choose titles yourself, then arrange them in priority and random lanes.",
53
+ role: "button",
54
+ })
55
+ })
56
+
57
+ test("a tile icon is decoration and stays out of the name", async () => {
58
+ const { canvas, canvasElement } =
59
+ await mountStory(WithIcons)
60
+
61
+ // Named by its words alone. An icon that joined the name would
62
+ // make this query fail, which is the whole reason it is
63
+ // `aria-hidden`.
64
+ expectAgentDrivable(canvas, {
65
+ name: "New arrivals Everything that arrived since the last run.",
66
+ role: "button",
67
+ })
68
+
69
+ await expectNoAxeViolations(canvasElement)
70
+ })
71
+
72
+ test("every tile is its own tab stop, and Enter and Space press it", async () => {
73
+ const { canvas } = await mountStory(Interactive)
74
+
75
+ const group = expectAgentDrivable(canvas, {
76
+ name: "Queue type",
77
+ role: "group",
78
+ })
79
+
80
+ const tiles = Array.from(
81
+ group.querySelectorAll<HTMLButtonElement>("button"),
82
+ )
83
+
84
+ // The opposite of the roving-tabindex rule, on purpose: a radio
85
+ // group borrows one tab stop because exactly one option is true at
86
+ // a time, and nothing here is true. Every tile must be reachable
87
+ // by Tab.
88
+ await expect(
89
+ tiles.filter((one) => one.tabIndex === 0),
90
+ ).toHaveLength(2)
91
+
92
+ tiles[0]?.focus()
93
+
94
+ await userEvent.tab()
95
+
96
+ await waitFor(() => {
97
+ expect(tiles[1]).toHaveFocus()
98
+ })
99
+
100
+ // Enter and Space are the platform's, because these are real
101
+ // `<button>`s — the two things the hand-rolled `<div role=button>`
102
+ // versions in the fleet re-implement by hand.
103
+ await userEvent.keyboard("{Enter}")
104
+
105
+ await userEvent.keyboard(" ")
106
+ })
107
+
108
+ test("a disabled tile cannot be pressed", async () => {
109
+ const { canvas, canvasElement } =
110
+ await mountStory(AllStates)
111
+
112
+ const group = expectAgentDrivable(canvas, {
113
+ name: "Queue type with Rules unavailable",
114
+ role: "group",
115
+ })
116
+
117
+ const rules = Array.from(
118
+ group.querySelectorAll<HTMLButtonElement>("button"),
119
+ ).find((one) => one.textContent?.startsWith("Rules"))
120
+
121
+ await expect(rules).toBeDisabled()
122
+
123
+ await expectNoAxeViolations(canvasElement)
124
+ })
125
+
126
+ test("an href tile is a real anchor, and an external one says so", async () => {
127
+ const { canvas, canvasElement } = await mountStory(Links)
128
+
129
+ const group = expectAgentDrivable(canvas, {
130
+ name: "Pick a tool",
131
+ role: "group",
132
+ })
133
+
134
+ const inspect = expectAgentDrivable(canvas, {
135
+ name: "Inspect Read a container's tracks and say what would change.",
136
+ role: "link",
137
+ })
138
+
139
+ // A real `<a href>`, so middle-click, ctrl-click, "open in a new
140
+ // tab" and "copy link address" come from the element rather than
141
+ // from the paint.
142
+ await expect(inspect.tagName).toBe("A")
143
+
144
+ await expect(inspect).toHaveAttribute(
145
+ "href",
146
+ "/tools/inspect",
147
+ )
148
+
149
+ const external = Array.from(
150
+ group.querySelectorAll<HTMLAnchorElement>("a"),
151
+ ).find((one) => one.target === "_blank")
152
+
153
+ // `noreferrer` alongside `noopener`: the first closes the
154
+ // `window.opener` hole, the second keeps a private app's URL out of
155
+ // the destination's logs.
156
+ await expect(external).toHaveAttribute(
157
+ "rel",
158
+ "noopener noreferrer",
159
+ )
160
+
161
+ await expectNoAxeViolations(canvasElement)
162
+ })
163
+
164
+ test("a disabled link drops its href and stays announced as a link", async () => {
165
+ const { canvas } = await mountStory(AllStates)
166
+
167
+ const remux = expectAgentDrivable(canvas, {
168
+ name: "Remux Nothing is loaded to remux yet.",
169
+ role: "link",
170
+ })
171
+
172
+ // An anchor with no `href` is inert and out of the tab order by the
173
+ // platform's own rules — better than a focusable one that silently
174
+ // ignores clicks. The explicit role keeps it *announced* rather
175
+ // than vanishing from the tree.
176
+ await expect(remux).not.toHaveAttribute("href")
177
+
178
+ await expect(remux).toHaveAttribute(
179
+ "aria-disabled",
180
+ "true",
181
+ )
182
+ })
183
+
184
+ /**
185
+ * The box is shared with `RadioGroup itemShape="tile"` through
186
+ * `tileStyles.ts`, and "shared" is asserted as **computed** styles
187
+ * rather than trusted — the same discipline `ButtonLink.test.tsx`
188
+ * applies to `Button`. Two copies of one string is a promise that
189
+ * survives exactly one edit.
190
+ */
191
+ test("an action tile is the same box as a resting radio tile", async () => {
192
+ const { canvas } = await mountStory(BesideTheRadioTile)
193
+
194
+ const actionTile = expectAgentDrivable(canvas, {
195
+ name: "Picks Choose titles yourself, then arrange them in priority and random lanes.",
196
+ role: "button",
197
+ })
198
+
199
+ const radioGroup = expectAgentDrivable(canvas, {
200
+ name: "Queue type, as radios",
201
+ role: "radiogroup",
202
+ })
203
+
204
+ // The SECOND radio tile, because `RadioGroup` checks its first
205
+ // option on mount and a checked tile wears the accent edge. A
206
+ // RESTING tile is what these two must match.
207
+ const radioTile =
208
+ radioGroup.querySelectorAll<HTMLElement>(
209
+ '[role="radio"]',
210
+ )[1]
211
+
212
+ if (!radioTile) {
213
+ throw new Error("the radio tile did not render")
214
+ }
215
+
216
+ const action = getComputedStyle(actionTile)
217
+
218
+ const radio = getComputedStyle(radioTile)
219
+
220
+ await expect(action.paddingTop).toBe(radio.paddingTop)
221
+
222
+ await expect(action.paddingBottom).toBe(
223
+ radio.paddingBottom,
224
+ )
225
+
226
+ await expect(action.paddingInlineStart).toBe(
227
+ radio.paddingInlineStart,
228
+ )
229
+
230
+ await expect(action.borderTopWidth).toBe(
231
+ radio.borderTopWidth,
232
+ )
233
+
234
+ await expect(action.borderTopColor).toBe(
235
+ radio.borderTopColor,
236
+ )
237
+
238
+ await expect(action.borderTopLeftRadius).toBe(
239
+ radio.borderTopLeftRadius,
240
+ )
241
+
242
+ await expect(action.backgroundColor).toBe(
243
+ radio.backgroundColor,
244
+ )
245
+
246
+ await expect(action.fontSize).toBe(radio.fontSize)
247
+
248
+ // The padding is the whole point. A `Button` with `height: auto` —
249
+ // the shape this component replaces — computes to `0px` here, with
250
+ // the title flush against the top border.
251
+ await expect(
252
+ Number.parseFloat(action.paddingTop),
253
+ ).toBeGreaterThan(0)
254
+ })
255
+
256
+ test("the column count comes from the container, not the window", async () => {
257
+ const { canvas } = await mountStory(Responsive)
258
+
259
+ const getColumnCount = (name: string) =>
260
+ getComputedStyle(
261
+ expectAgentDrivable(canvas, { name, role: "group" }),
262
+ ).gridTemplateColumns.split(" ").length
263
+
264
+ // One viewport, three container widths, and the answer changes
265
+ // with the container. Every panel is the same window.
266
+ await expect(
267
+ getColumnCount("Where to start at 15rem"),
268
+ ).toBe(1)
269
+
270
+ await expect(
271
+ getColumnCount("Where to start at 34rem"),
272
+ ).toBe(2)
273
+ })
274
+
275
+ test("a tile grid never overflows a container narrower than one tile", async () => {
276
+ const { canvas } = await mountStory(Responsive)
277
+
278
+ const group = expectAgentDrivable(canvas, {
279
+ name: "Where to start at 15rem",
280
+ role: "group",
281
+ })
282
+
283
+ // The case that matters is the `min(…, 100%)` in the track floor,
284
+ // which is what stops a 200px floor from becoming a horizontal
285
+ // scrollbar in the Narrow View.
286
+ await expect(
287
+ group.getBoundingClientRect().width,
288
+ ).toBeLessThanOrEqual(
289
+ (group.parentElement?.getBoundingClientRect().width ??
290
+ 0) + 1,
291
+ )
292
+ })