@charcuterie/ui 3.14.4 → 3.16.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 +35 -0
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/RadioGroup/RadioGroup.d.ts +49 -1
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroup.js +58 -3
- package/dist/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts +13 -2
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.js +62 -5
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
- package/dist/Stepper/Stepper.d.ts.map +1 -1
- package/dist/Stepper/Stepper.js +2 -1
- package/dist/Stepper/Stepper.js.map +1 -1
- package/package.json +1 -1
- package/src/Combobox/Combobox.stories.tsx +16 -5
- package/src/Combobox/Combobox.test.tsx +45 -7
- package/src/Combobox/Combobox.tsx +44 -0
- package/src/RadioGroup/RadioGroup.mdx +62 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +164 -1
- package/src/RadioGroup/RadioGroup.test.tsx +179 -2
- package/src/RadioGroup/RadioGroup.tsx +107 -3
- package/src/RadioGroup/RadioGroupOption.tsx +122 -5
- package/src/Stepper/Stepper.test.tsx +17 -0
- package/src/Stepper/Stepper.tsx +21 -0
|
@@ -478,9 +478,11 @@ test("opening lands on the chosen option, not the top of the list", async () =>
|
|
|
478
478
|
role: "combobox",
|
|
479
479
|
})
|
|
480
480
|
|
|
481
|
-
//
|
|
481
|
+
// Item 58 of 62, far past the panel's height cap — index 0 would be
|
|
482
|
+
// the old behaviour, and a row still off screen would be the fix
|
|
483
|
+
// landing only halfway.
|
|
482
484
|
const chosen = mounted.body.getByRole("option", {
|
|
483
|
-
name: "
|
|
485
|
+
name: "Item 58",
|
|
484
486
|
})
|
|
485
487
|
|
|
486
488
|
await waitFor(() => {
|
|
@@ -515,9 +517,9 @@ test("typing reseeds the highlight to the top match, not the chosen row", async
|
|
|
515
517
|
role: "combobox",
|
|
516
518
|
})
|
|
517
519
|
|
|
518
|
-
// Matches "
|
|
519
|
-
//
|
|
520
|
-
await userEvent.type(input, "
|
|
520
|
+
// Matches "Item 1", "Item 10"… — the open seed must not drag the
|
|
521
|
+
// highlight down to the chosen row.
|
|
522
|
+
await userEvent.type(input, "1")
|
|
521
523
|
|
|
522
524
|
await waitFor(() => {
|
|
523
525
|
expect(input).toHaveAttribute(
|
|
@@ -538,7 +540,7 @@ test("reopening after a pick lands on what was just picked", async () => {
|
|
|
538
540
|
await userEvent.click(trigger)
|
|
539
541
|
|
|
540
542
|
await userEvent.click(
|
|
541
|
-
mounted.body.getByRole("option", { name: "
|
|
543
|
+
mounted.body.getByRole("option", { name: "Item 3" }),
|
|
542
544
|
)
|
|
543
545
|
|
|
544
546
|
await waitFor(() => {
|
|
@@ -554,7 +556,7 @@ test("reopening after a pick lands on what was just picked", async () => {
|
|
|
554
556
|
await waitFor(() => {
|
|
555
557
|
expect(reopened).toHaveAttribute(
|
|
556
558
|
"aria-activedescendant",
|
|
557
|
-
mounted.body.getByRole("option", { name: "
|
|
559
|
+
mounted.body.getByRole("option", { name: "Item 3" })
|
|
558
560
|
.id,
|
|
559
561
|
)
|
|
560
562
|
})
|
|
@@ -578,3 +580,39 @@ test("a windowed list scrolls its chosen option into the window on open", async
|
|
|
578
580
|
body.queryByRole("option", { name: "Track 1" }),
|
|
579
581
|
).toBeNull()
|
|
580
582
|
})
|
|
583
|
+
|
|
584
|
+
test("the chosen row is scrolled into view, not merely highlighted", async () => {
|
|
585
|
+
const mounted = await mountStory(ChosenValueOnOpen)
|
|
586
|
+
|
|
587
|
+
await userEvent.click(
|
|
588
|
+
expectAgentDrivable(mounted.canvas, {
|
|
589
|
+
name: "Search languages",
|
|
590
|
+
role: "button",
|
|
591
|
+
}),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
const chosen = mounted.body.getByRole("option", {
|
|
595
|
+
name: "Item 58",
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
// The panel's height cap is written imperatively by floating-ui's
|
|
599
|
+
// `size` middleware, a frame after the open seed runs — so this is the
|
|
600
|
+
// assertion the first version of this fix passed while the row sat off
|
|
601
|
+
// screen in the real app. Highlighting it is not the same as showing
|
|
602
|
+
// it.
|
|
603
|
+
await waitFor(() => {
|
|
604
|
+
const list = chosen.closest("[role=listbox]")
|
|
605
|
+
|
|
606
|
+
expect(list).not.toBeNull()
|
|
607
|
+
|
|
608
|
+
const listBox = (
|
|
609
|
+
list as HTMLElement
|
|
610
|
+
).getBoundingClientRect()
|
|
611
|
+
const rowBox = chosen.getBoundingClientRect()
|
|
612
|
+
|
|
613
|
+
expect(rowBox.top).toBeGreaterThanOrEqual(listBox.top)
|
|
614
|
+
expect(rowBox.bottom).toBeLessThanOrEqual(
|
|
615
|
+
listBox.bottom,
|
|
616
|
+
)
|
|
617
|
+
})
|
|
618
|
+
})
|
|
@@ -71,6 +71,13 @@ export type ComboboxProps = {
|
|
|
71
71
|
|
|
72
72
|
const AUTO_VIRTUALIZE_THRESHOLD = 100
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* How many frames the open seed will wait for the panel to be capped
|
|
76
|
+
* before it gives up on centring the chosen row. A few is all
|
|
77
|
+
* floating-ui's `size` middleware needs.
|
|
78
|
+
*/
|
|
79
|
+
const SEED_SCROLL_ATTEMPTS = 5
|
|
80
|
+
|
|
74
81
|
const getOptionText = (item: ListboxItem) =>
|
|
75
82
|
(
|
|
76
83
|
item.textValue ??
|
|
@@ -288,6 +295,19 @@ export const Combobox = ({
|
|
|
288
295
|
// short of it.
|
|
289
296
|
const pendingSeedIndex = useRef<null | number>(null)
|
|
290
297
|
|
|
298
|
+
// Retrying the seed's centring scroll. `useAnchoredOverlay` caps the
|
|
299
|
+
// panel's height by writing `style.maxHeight` straight onto the
|
|
300
|
+
// floating element inside floating-ui's `size` middleware —
|
|
301
|
+
// deliberately outside the `floatingStyles` React manages, so a
|
|
302
|
+
// keystroke cannot wipe it. The cost lands here: it arrives a frame
|
|
303
|
+
// after the open seed, announces itself with no re-render, and until
|
|
304
|
+
// it does the list stands at its full content height with **nothing to
|
|
305
|
+
// scroll**. A seed scroll issued then is dropped without a trace,
|
|
306
|
+
// which is how the first build of this fix highlighted the chosen row
|
|
307
|
+
// and still left it off screen in a real app.
|
|
308
|
+
const [seedScrollAttempt, setSeedScrollAttempt] =
|
|
309
|
+
useState(0)
|
|
310
|
+
|
|
291
311
|
useEffect(() => {
|
|
292
312
|
if (!isVisible) {
|
|
293
313
|
hasSeededOnOpen.current = false
|
|
@@ -319,6 +339,8 @@ export const Combobox = ({
|
|
|
319
339
|
|
|
320
340
|
pendingSeedIndex.current = openSeedIndex
|
|
321
341
|
|
|
342
|
+
setSeedScrollAttempt(0)
|
|
343
|
+
|
|
322
344
|
setActiveIndex(openSeedIndex)
|
|
323
345
|
}, [
|
|
324
346
|
isVisible,
|
|
@@ -536,6 +558,27 @@ export const Combobox = ({
|
|
|
536
558
|
return
|
|
537
559
|
}
|
|
538
560
|
|
|
561
|
+
const list = listElement.current
|
|
562
|
+
|
|
563
|
+
if (
|
|
564
|
+
isSeeding &&
|
|
565
|
+
seedScrollAttempt < SEED_SCROLL_ATTEMPTS &&
|
|
566
|
+
list !== null &&
|
|
567
|
+
list.scrollHeight <= list.clientHeight
|
|
568
|
+
) {
|
|
569
|
+
// Nothing to scroll yet — the panel is still at its full height.
|
|
570
|
+
// Hold the seed and look again next frame rather than scroll a
|
|
571
|
+
// container that cannot move. Bounded, so a list that genuinely
|
|
572
|
+
// fits its panel stops instead of spinning.
|
|
573
|
+
const frame = requestAnimationFrame(() => {
|
|
574
|
+
setSeedScrollAttempt((attempt) => attempt + 1)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
return () => {
|
|
578
|
+
cancelAnimationFrame(frame)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
539
582
|
pendingSeedIndex.current = null
|
|
540
583
|
|
|
541
584
|
// The open seed centres its row; an arrow move only pulls the next
|
|
@@ -567,6 +610,7 @@ export const Combobox = ({
|
|
|
567
610
|
isWindowed,
|
|
568
611
|
listboxId,
|
|
569
612
|
rowVirtualizer.scrollToIndex,
|
|
613
|
+
seedScrollAttempt,
|
|
570
614
|
])
|
|
571
615
|
|
|
572
616
|
// ─── Attached mode: drive the consumer's input ──────────────────────
|
|
@@ -47,6 +47,68 @@ at.
|
|
|
47
47
|
|
|
48
48
|
<Canvas of={RadioGroupStories.AllStates} />
|
|
49
49
|
|
|
50
|
+
## `itemShape="tile"` — the choice tile
|
|
51
|
+
|
|
52
|
+
A bordered card carrying a name, a line of help and a selected surface, laid out in a grid
|
|
53
|
+
that gains columns with its **container**. It is a prop rather than a third component
|
|
54
|
+
because **only the box changes**: the element, the `radio` role, the roving tab stop,
|
|
55
|
+
selection-follows-focus and `isReadOnly` are the ones already tested on the row shape. A
|
|
56
|
+
tile with its border taken away is a row.
|
|
57
|
+
|
|
58
|
+
`SegmentedControl` earns its own file by being a different *control* — one connected strip,
|
|
59
|
+
no radio affordance, options too short to carry a sentence. A tile is this control with a
|
|
60
|
+
border, which is a different reason.
|
|
61
|
+
|
|
62
|
+
<Canvas of={RadioGroupStories.Tiles} />
|
|
63
|
+
|
|
64
|
+
Four apps in the fleet had each hand-painted this shape — ten instances between them — and
|
|
65
|
+
**not one carried `aria-checked`**. Every one was a bare `<button>` or `<a>` whose selection
|
|
66
|
+
was visible only as colour: correct to an eye, silent to a screen reader, and announced as
|
|
67
|
+
neither pressed nor "3 of 6". That is what this prop is for, and it is why the semantics are
|
|
68
|
+
not the part an app should be writing.
|
|
69
|
+
|
|
70
|
+
Eight of the ten had a **second line** under the name, which is also why none of them
|
|
71
|
+
reached for `RadioGroup`: a plain-text `label` could not express it. `hint` can, and it
|
|
72
|
+
renders on a row as readily as on a tile.
|
|
73
|
+
|
|
74
|
+
### The selected tile is an edge and a surface, never a fill
|
|
75
|
+
|
|
76
|
+
The chosen tile takes the accent on its **border** and lifts to `surface-overlay`; the radio
|
|
77
|
+
dot inside it is unchanged and is still the thing a screen reader reads. A state told in
|
|
78
|
+
colour alone is the one state a monochrome ePaper build cannot show at all — the same reason
|
|
79
|
+
`Stepper` refuses to say "done" with a hue.
|
|
80
|
+
|
|
81
|
+
### A tile may lead with an icon
|
|
82
|
+
|
|
83
|
+
`icon` is `tile` only — a row already leads with its radio, and a second mark either side of
|
|
84
|
+
it has nowhere to sit — and it is `aria-hidden` by construction, because the name it sits
|
|
85
|
+
above is inside the same button. The library still ships no icons; apps bring their own.
|
|
86
|
+
|
|
87
|
+
<Canvas of={RadioGroupStories.TilesWithIcons} />
|
|
88
|
+
|
|
89
|
+
## The grid takes its columns from the container
|
|
90
|
+
|
|
91
|
+
`repeat(auto-fill, minmax(min(var(--charcuterie-tile-min-inline-size), 100%), 1fr))`, and
|
|
92
|
+
each of those three parts is load-bearing:
|
|
93
|
+
|
|
94
|
+
- **`auto-fill`, not `auto-fit`.** `auto-fit` collapses the empty tracks and lets the
|
|
95
|
+
survivors share the whole row, so six tiles in a 2560px container become six 420px slabs —
|
|
96
|
+
the full-width-row shape in a new costume. `auto-fill` keeps the empty tracks, so a tile
|
|
97
|
+
stays a tile and the grid simply has room to spare.
|
|
98
|
+
- **`min(…, 100%)`.** Without it the 200px floor overflows a container narrower than one
|
|
99
|
+
tile, which is a horizontal scrollbar in the Narrow View.
|
|
100
|
+
- **A custom property, not an interpolated class.** Tailwind scans source *text* for
|
|
101
|
+
complete class strings, so `` `grid-cols-[…${n}px…]` `` generates nothing, paints nothing
|
|
102
|
+
and reports nothing. `minTileInlineSize` lands in `--charcuterie-tile-min-inline-size`,
|
|
103
|
+
which one written-out literal reads.
|
|
104
|
+
|
|
105
|
+
This is **not** `useAdaptiveColumns`, and the difference is which question is being asked.
|
|
106
|
+
That hook buys a column with **height**, for an unbounded gallery that is going to scroll. A
|
|
107
|
+
radio group is a bounded set of options inside a form section, where the only question is
|
|
108
|
+
how many fit across the box it was handed.
|
|
109
|
+
|
|
110
|
+
<Canvas of={RadioGroupStories.Responsive} />
|
|
111
|
+
|
|
50
112
|
## A read-only group shows the choice without moving it
|
|
51
113
|
|
|
52
114
|
`isReadOnly` severs the one link that defines this component — selection follows focus — so
|
|
@@ -2,10 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react"
|
|
|
2
2
|
|
|
3
3
|
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
4
4
|
import {
|
|
5
|
+
ContainerBoard,
|
|
5
6
|
StoryCell,
|
|
6
7
|
StoryGrid,
|
|
7
8
|
StorySection,
|
|
8
9
|
} from "../board.storyHelpers.tsx"
|
|
10
|
+
import {
|
|
11
|
+
InboxIcon,
|
|
12
|
+
PlayIcon,
|
|
13
|
+
SearchIcon,
|
|
14
|
+
} from "../icons.storyHelpers.tsx"
|
|
9
15
|
import type { RadioItem } from "./RadioGroup.tsx"
|
|
10
16
|
import { RadioGroup } from "./RadioGroup.tsx"
|
|
11
17
|
|
|
@@ -23,6 +29,55 @@ const NAMING_ITEMS: RadioItem[] = [
|
|
|
23
29
|
{ label: "Use a custom pattern", value: "custom" },
|
|
24
30
|
]
|
|
25
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The same control with a name and a line of help per option — the
|
|
34
|
+
* shape four apps in the fleet had each hand-painted, and the reason
|
|
35
|
+
* none of them reached for this component.
|
|
36
|
+
*/
|
|
37
|
+
const IMPORT_ITEMS: RadioItem[] = [
|
|
38
|
+
{
|
|
39
|
+
hint: "Nothing is left behind at the source.",
|
|
40
|
+
label: "Move the files",
|
|
41
|
+
value: "move",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
hint: "Twice the space, and the source is untouched.",
|
|
45
|
+
label: "Copy the files",
|
|
46
|
+
value: "copy",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
hint: "One copy on disk, two names for it. Same volume only.",
|
|
50
|
+
label: "Hard link",
|
|
51
|
+
value: "link",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
hint: "Catalogue them where they are.",
|
|
55
|
+
label: "Leave in place",
|
|
56
|
+
value: "leave",
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const ICON_ITEMS: RadioItem[] = [
|
|
61
|
+
{
|
|
62
|
+
hint: "Everything that arrived since the last run.",
|
|
63
|
+
icon: <InboxIcon />,
|
|
64
|
+
label: "New arrivals",
|
|
65
|
+
value: "new",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
hint: "Whatever the saved query matches today.",
|
|
69
|
+
icon: <SearchIcon />,
|
|
70
|
+
label: "Saved search",
|
|
71
|
+
value: "search",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
hint: "Pick up where the last session stopped.",
|
|
75
|
+
icon: <PlayIcon />,
|
|
76
|
+
label: "Continue",
|
|
77
|
+
value: "continue",
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
|
|
26
81
|
const meta = {
|
|
27
82
|
title: "Components/Controls/RadioGroup",
|
|
28
83
|
component: RadioGroup,
|
|
@@ -31,7 +86,12 @@ const meta = {
|
|
|
31
86
|
// The component's own defaults, restated — Storybook does not seed
|
|
32
87
|
// `args` from docgen, so an unstated default shows in the props
|
|
33
88
|
// table with nothing selected in its control.
|
|
34
|
-
args: {
|
|
89
|
+
args: {
|
|
90
|
+
isReadOnly: false,
|
|
91
|
+
itemShape: "row",
|
|
92
|
+
minTileInlineSize: 200,
|
|
93
|
+
size: "md",
|
|
94
|
+
},
|
|
35
95
|
} satisfies Meta<typeof RadioGroup>
|
|
36
96
|
|
|
37
97
|
export default meta
|
|
@@ -61,6 +121,38 @@ export const AllVariants: Story = {
|
|
|
61
121
|
),
|
|
62
122
|
}
|
|
63
123
|
|
|
124
|
+
/**
|
|
125
|
+
* `itemShape="tile"` is the same `radiogroup` in a box: one choice
|
|
126
|
+
* out of several, exclusive, announced as "3 of 6", drawn as cards
|
|
127
|
+
* in a grid that gains columns with its **container**.
|
|
128
|
+
*
|
|
129
|
+
* The selected tile is an accent **edge** and a lifted surface, not
|
|
130
|
+
* a fill — the radio dot inside it is still the thing a screen
|
|
131
|
+
* reader reads, and a state told in colour alone is the one state a
|
|
132
|
+
* monochrome ePaper build cannot show.
|
|
133
|
+
*/
|
|
134
|
+
export const Tiles: Story = {
|
|
135
|
+
args: {
|
|
136
|
+
items: IMPORT_ITEMS,
|
|
137
|
+
itemShape: "tile",
|
|
138
|
+
label: "On import",
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A tile may lead with an icon. The library ships none — these are
|
|
144
|
+
* story-only SVGs, exactly what an app passes — and the icon is
|
|
145
|
+
* `aria-hidden` by construction, because the name it sits above is
|
|
146
|
+
* in the same button.
|
|
147
|
+
*/
|
|
148
|
+
export const TilesWithIcons: Story = {
|
|
149
|
+
args: {
|
|
150
|
+
items: ICON_ITEMS,
|
|
151
|
+
itemShape: "tile",
|
|
152
|
+
label: "Where to start",
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
64
156
|
const STATE_ITEMS: RadioItem[] = [
|
|
65
157
|
{ label: "Match the Plex agent", value: "plex" },
|
|
66
158
|
{ label: "Match AniDB titles", value: "anidb" },
|
|
@@ -125,11 +217,82 @@ export const AllStates: Story = {
|
|
|
125
217
|
selectedValue="anidb"
|
|
126
218
|
/>
|
|
127
219
|
</StoryCell>
|
|
220
|
+
|
|
221
|
+
<StoryCell align="stretch" label="a hint on a row">
|
|
222
|
+
<RadioGroup
|
|
223
|
+
{...controlProps}
|
|
224
|
+
items={IMPORT_ITEMS}
|
|
225
|
+
label="On import, as rows"
|
|
226
|
+
/>
|
|
227
|
+
</StoryCell>
|
|
228
|
+
|
|
229
|
+
<StoryCell
|
|
230
|
+
align="stretch"
|
|
231
|
+
label="tiles, third chosen"
|
|
232
|
+
>
|
|
233
|
+
<RadioGroup
|
|
234
|
+
{...controlProps}
|
|
235
|
+
items={IMPORT_ITEMS}
|
|
236
|
+
itemShape="tile"
|
|
237
|
+
label="On import, as tiles"
|
|
238
|
+
selectedValue="link"
|
|
239
|
+
/>
|
|
240
|
+
</StoryCell>
|
|
241
|
+
|
|
242
|
+
<StoryCell
|
|
243
|
+
align="stretch"
|
|
244
|
+
label="tiles, read-only and disabled"
|
|
245
|
+
>
|
|
246
|
+
<RadioGroup
|
|
247
|
+
{...controlProps}
|
|
248
|
+
isReadOnly
|
|
249
|
+
items={[
|
|
250
|
+
...IMPORT_ITEMS.slice(0, 2),
|
|
251
|
+
{
|
|
252
|
+
hint: "The source volume is read-only.",
|
|
253
|
+
isDisabled: true,
|
|
254
|
+
label: "Hard link",
|
|
255
|
+
value: "link",
|
|
256
|
+
},
|
|
257
|
+
]}
|
|
258
|
+
itemShape="tile"
|
|
259
|
+
label="On import, read-only tiles"
|
|
260
|
+
selectedValue="copy"
|
|
261
|
+
/>
|
|
262
|
+
</StoryCell>
|
|
128
263
|
</StoryGrid>
|
|
129
264
|
</StorySection>
|
|
130
265
|
),
|
|
131
266
|
}
|
|
132
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Column count comes from the **container**, never the window — the
|
|
270
|
+
* standing rule for any list of cards, and the reason this board
|
|
271
|
+
* varies a wrapper's width rather than the viewport's.
|
|
272
|
+
*
|
|
273
|
+
* At the 200px default floor: one track at 15rem, one at 24rem, two
|
|
274
|
+
* at 34rem. `auto-fill` and deliberately not `auto-fit`, so the
|
|
275
|
+
* tiles keep their size in a container with room to spare instead of
|
|
276
|
+
* stretching to fill it.
|
|
277
|
+
*/
|
|
278
|
+
export const Responsive: Story = {
|
|
279
|
+
args: {
|
|
280
|
+
items: IMPORT_ITEMS,
|
|
281
|
+
itemShape: "tile",
|
|
282
|
+
label: "On import",
|
|
283
|
+
},
|
|
284
|
+
render: (controlProps) => (
|
|
285
|
+
<ContainerBoard>
|
|
286
|
+
{(width) => (
|
|
287
|
+
<RadioGroup
|
|
288
|
+
{...controlProps}
|
|
289
|
+
label={`On import at ${width}`}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
</ContainerBoard>
|
|
293
|
+
),
|
|
294
|
+
}
|
|
295
|
+
|
|
133
296
|
/**
|
|
134
297
|
* The complete keyboard path. Tab enters the group **once** — the
|
|
135
298
|
* roving-tabindex rule — then the arrow keys move and check
|
|
@@ -7,8 +7,14 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./RadioGroup.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
Default,
|
|
13
|
+
Interactive,
|
|
14
|
+
Responsive,
|
|
15
|
+
Tiles,
|
|
16
|
+
TilesWithIcons,
|
|
17
|
+
} = composeStories(stories)
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* `querySelectorAll` rather than `getAllByRole("radio")`: a board
|
|
@@ -230,3 +236,174 @@ test("a read-only group is announced and its choice cannot move", async () => {
|
|
|
230
236
|
|
|
231
237
|
await expectNoAxeViolations(canvasElement)
|
|
232
238
|
})
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The tile shape is the same control in a box, so what these assert
|
|
242
|
+
* is that **nothing about the control changed** — role, exclusivity,
|
|
243
|
+
* the roving tab stop and selection-follows-focus are the ones
|
|
244
|
+
* already proven above, driven through the tile story.
|
|
245
|
+
*/
|
|
246
|
+
test("a tile group is still a radiogroup of radios", async () => {
|
|
247
|
+
const { canvas, canvasElement } = await mountStory(Tiles)
|
|
248
|
+
|
|
249
|
+
const group = expectAgentDrivable(canvas, {
|
|
250
|
+
name: "On import",
|
|
251
|
+
role: "radiogroup",
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const options = getOptions(group)
|
|
255
|
+
|
|
256
|
+
await expect(options).toHaveLength(4)
|
|
257
|
+
|
|
258
|
+
await waitFor(() => {
|
|
259
|
+
expect(
|
|
260
|
+
options.filter(
|
|
261
|
+
(one) =>
|
|
262
|
+
one.getAttribute("aria-checked") === "true",
|
|
263
|
+
),
|
|
264
|
+
).toHaveLength(1)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// Exactly one tab stop — the roving rule survives the layout
|
|
268
|
+
// change, because the layout is all that changed.
|
|
269
|
+
await expect(
|
|
270
|
+
options.filter((one) => one.tabIndex === 0),
|
|
271
|
+
).toHaveLength(1)
|
|
272
|
+
|
|
273
|
+
await expectNoAxeViolations(canvasElement)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test("a tile's hint is part of the option's accessible name", async () => {
|
|
277
|
+
const { canvas } = await mountStory(Tiles)
|
|
278
|
+
|
|
279
|
+
// The reason the hint is rendered inside the button rather than
|
|
280
|
+
// beside it: a hint a screen reader never reads is a hint half the
|
|
281
|
+
// audience does not have. `getByRole` computes the name from
|
|
282
|
+
// contents, so this query only passes if it is in there.
|
|
283
|
+
expectAgentDrivable(canvas, {
|
|
284
|
+
name: "Hard link One copy on disk, two names for it. Same volume only.",
|
|
285
|
+
role: "radio",
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test("a tile icon is decoration and stays out of the name", async () => {
|
|
290
|
+
const { canvas, canvasElement } =
|
|
291
|
+
await mountStory(TilesWithIcons)
|
|
292
|
+
|
|
293
|
+
// Named by its words alone. An icon that joined the name would
|
|
294
|
+
// make this query fail, which is the whole reason it is
|
|
295
|
+
// `aria-hidden`.
|
|
296
|
+
expectAgentDrivable(canvas, {
|
|
297
|
+
name: "New arrivals Everything that arrived since the last run.",
|
|
298
|
+
role: "radio",
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
await expectNoAxeViolations(canvasElement)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test("the arrow keys still move and check inside a tile grid", async () => {
|
|
305
|
+
const { canvas } = await mountStory(Tiles)
|
|
306
|
+
|
|
307
|
+
const options = getOptions(
|
|
308
|
+
expectAgentDrivable(canvas, {
|
|
309
|
+
name: "On import",
|
|
310
|
+
role: "radiogroup",
|
|
311
|
+
}),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
options[0]?.focus()
|
|
315
|
+
|
|
316
|
+
// ArrowRight in a grid is `focus.next`, exactly as ArrowDown is in
|
|
317
|
+
// a stack. APG gives a radio group one traversal order, and a grid
|
|
318
|
+
// does not earn a second one.
|
|
319
|
+
await userEvent.keyboard("{ArrowRight}")
|
|
320
|
+
|
|
321
|
+
await waitFor(() => {
|
|
322
|
+
expect(options[1]).toHaveAttribute(
|
|
323
|
+
"aria-checked",
|
|
324
|
+
"true",
|
|
325
|
+
)
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The three things about the box that fail *silently*, so all three
|
|
331
|
+
* are read off `getComputedStyle` in a real browser rather than off
|
|
332
|
+
* a class name. A class-name assertion would have passed while the
|
|
333
|
+
* grid was one column, because the class really was in the DOM.
|
|
334
|
+
*/
|
|
335
|
+
test("a tile carries a border, and the chosen one carries the accent", async () => {
|
|
336
|
+
const { canvas } = await mountStory(Tiles)
|
|
337
|
+
|
|
338
|
+
const options = getOptions(
|
|
339
|
+
expectAgentDrivable(canvas, {
|
|
340
|
+
name: "On import",
|
|
341
|
+
role: "radiogroup",
|
|
342
|
+
}),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
const [first, second] = options as [
|
|
346
|
+
HTMLButtonElement,
|
|
347
|
+
HTMLButtonElement,
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
await waitFor(() => {
|
|
351
|
+
expect(first).toHaveAttribute("aria-checked", "true")
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const chosen = getComputedStyle(first)
|
|
355
|
+
|
|
356
|
+
const other = getComputedStyle(second)
|
|
357
|
+
|
|
358
|
+
await expect(
|
|
359
|
+
Number.parseFloat(chosen.borderTopWidth),
|
|
360
|
+
).toBeGreaterThan(0)
|
|
361
|
+
|
|
362
|
+
// A border and a surface, never colour alone — but the two tiles
|
|
363
|
+
// must still differ, or "chosen" is told by the radio dot only.
|
|
364
|
+
await expect(chosen.borderTopColor).not.toBe(
|
|
365
|
+
other.borderTopColor,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
await expect(chosen.backgroundColor).not.toBe(
|
|
369
|
+
other.backgroundColor,
|
|
370
|
+
)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test("the column count comes from the container, not the window", async () => {
|
|
374
|
+
const { canvas } = await mountStory(Responsive)
|
|
375
|
+
|
|
376
|
+
const getColumnCount = (name: string) =>
|
|
377
|
+
getComputedStyle(
|
|
378
|
+
expectAgentDrivable(canvas, {
|
|
379
|
+
name,
|
|
380
|
+
role: "radiogroup",
|
|
381
|
+
}),
|
|
382
|
+
).gridTemplateColumns.split(" ").length
|
|
383
|
+
|
|
384
|
+
// One viewport, three container widths, and the answer changes
|
|
385
|
+
// with the container. Every panel is the same window.
|
|
386
|
+
await expect(getColumnCount("On import at 15rem")).toBe(1)
|
|
387
|
+
|
|
388
|
+
await expect(getColumnCount("On import at 34rem")).toBe(2)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test("a tile grid never overflows a container narrower than one tile", async () => {
|
|
392
|
+
const { canvas } = await mountStory(Responsive)
|
|
393
|
+
|
|
394
|
+
const group = expectAgentDrivable(canvas, {
|
|
395
|
+
name: "On import at 15rem",
|
|
396
|
+
role: "radiogroup",
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// 15rem is 240px and the tile floor is 200px, so this passes
|
|
400
|
+
// either way — the case that matters is the `min(…, 100%)`, which
|
|
401
|
+
// is what stops a 200px floor from becoming a horizontal
|
|
402
|
+
// scrollbar in the Narrow View.
|
|
403
|
+
await expect(
|
|
404
|
+
group.getBoundingClientRect().width,
|
|
405
|
+
).toBeLessThanOrEqual(
|
|
406
|
+
(group.parentElement?.getBoundingClientRect().width ??
|
|
407
|
+
0) + 1,
|
|
408
|
+
)
|
|
409
|
+
})
|