@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.
- package/README.md +14 -0
- package/dist/ActionTiles/ActionTiles.d.ts +115 -0
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -0
- package/dist/ActionTiles/ActionTiles.js +115 -0
- package/dist/ActionTiles/ActionTiles.js.map +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +16 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
- package/dist/AdaptiveGrid/useAdaptiveColumns.js +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
- package/dist/RadioGroup/RadioGroup.d.ts +10 -0
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroup.js +12 -39
- package/dist/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.js +3 -33
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +35 -0
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js +37 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tileStyles.d.ts +83 -0
- package/dist/tileStyles.d.ts.map +1 -0
- package/dist/tileStyles.js +98 -0
- package/dist/tileStyles.js.map +1 -0
- package/package.json +1 -1
- package/src/ActionTiles/ActionTiles.mdx +131 -0
- package/src/ActionTiles/ActionTiles.stories.tsx +311 -0
- package/src/ActionTiles/ActionTiles.test.tsx +292 -0
- package/src/ActionTiles/ActionTiles.tsx +292 -0
- package/src/AdaptiveGrid/useAdaptiveColumns.ts +17 -0
- package/src/RadioGroup/RadioGroup.mdx +7 -0
- package/src/RadioGroup/RadioGroup.tsx +16 -42
- package/src/RadioGroup/RadioGroupOption.tsx +8 -38
- package/src/VirtualizedGrid/VirtualizedGrid.mdx +21 -0
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +64 -0
- package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +122 -3
- package/src/VirtualizedGrid/VirtualizedGrid.tsx +37 -1
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +9 -1
- 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
|
+
})
|