@charcuterie/ui 2.4.1 → 2.5.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/Checkbox/Checkbox.d.ts +59 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/Checkbox/Checkbox.js +61 -0
- package/dist/Checkbox/Checkbox.js.map +1 -0
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +12 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/Menu/Menu.d.ts +60 -2
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +58 -11
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/RadioGroup/RadioGroup.d.ts +47 -0
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -0
- package/dist/RadioGroup/RadioGroup.js +114 -0
- package/dist/RadioGroup/RadioGroup.js.map +1 -0
- package/dist/RadioGroup/RadioGroupOption.d.ts +26 -0
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -0
- package/dist/RadioGroup/RadioGroupOption.js +52 -0
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -0
- package/dist/Switch/Switch.d.ts +54 -0
- package/dist/Switch/Switch.d.ts.map +1 -0
- package/dist/Switch/Switch.js +94 -0
- package/dist/Switch/Switch.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/slotWiring.d.ts +9 -7
- package/dist/slotWiring.d.ts.map +1 -1
- package/dist/slotWiring.js +10 -70
- package/dist/slotWiring.js.map +1 -1
- package/package.json +2 -2
- package/src/Checkbox/Checkbox.mdx +58 -0
- package/src/Checkbox/Checkbox.stories.tsx +100 -0
- package/src/Checkbox/Checkbox.test.tsx +80 -0
- package/src/Checkbox/Checkbox.tsx +146 -0
- package/src/Combobox/Combobox.stories.tsx +19 -4
- package/src/Field/Field.stories.tsx +19 -0
- package/src/Field/Field.test.tsx +30 -2
- package/src/Field/Field.tsx +13 -1
- package/src/Menu/Menu.stories.tsx +80 -2
- package/src/Menu/Menu.test.tsx +102 -0
- package/src/Menu/Menu.tsx +156 -17
- package/src/RadioGroup/RadioGroup.mdx +58 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +130 -0
- package/src/RadioGroup/RadioGroup.test.tsx +191 -0
- package/src/RadioGroup/RadioGroup.tsx +204 -0
- package/src/RadioGroup/RadioGroupOption.tsx +126 -0
- package/src/Switch/Switch.mdx +53 -0
- package/src/Switch/Switch.stories.tsx +94 -0
- package/src/Switch/Switch.test.tsx +86 -0
- package/src/Switch/Switch.tsx +162 -0
- package/src/index.ts +9 -0
- package/src/slotWiring.ts +15 -107
- package/src/sourceRules.test.ts +11 -1
- package/src/tailwindCandidates.test.ts +9 -7
package/src/Menu/Menu.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
selectTabIndex,
|
|
3
3
|
useRovingFocus,
|
|
4
|
+
useUniqueId,
|
|
4
5
|
} from "@charcuterie/logic"
|
|
5
6
|
import type { Placement } from "@floating-ui/react"
|
|
6
7
|
import {
|
|
@@ -21,12 +22,57 @@ export type MenuItem = {
|
|
|
21
22
|
key: string
|
|
22
23
|
label: ReactNode
|
|
23
24
|
onSelect: () => void
|
|
25
|
+
/**
|
|
26
|
+
* The default kind, and optional so the common `{ key, label,
|
|
27
|
+
* onSelect }` still type-checks. Present only to discriminate this
|
|
28
|
+
* from a `separator` or a `group`.
|
|
29
|
+
*/
|
|
30
|
+
type?: "item"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A rule between groups of items — `role="separator"`, and nothing a
|
|
35
|
+
* keyboard user lands on. It never registers with the roving group,
|
|
36
|
+
* so the arrow keys pass straight over it, the same mechanism a
|
|
37
|
+
* disabled item uses.
|
|
38
|
+
*/
|
|
39
|
+
export type MenuSeparator = {
|
|
40
|
+
key: string
|
|
41
|
+
type: "separator"
|
|
24
42
|
}
|
|
25
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A named set of items — `role="group"` with the `label` as its
|
|
46
|
+
* accessible name (a screen reader announces "Danger, group" before
|
|
47
|
+
* its members). The heading itself is not focusable; only the items
|
|
48
|
+
* inside it register.
|
|
49
|
+
*/
|
|
50
|
+
export type MenuGroup = {
|
|
51
|
+
items: MenuItem[]
|
|
52
|
+
key: string
|
|
53
|
+
label: ReactNode
|
|
54
|
+
type: "group"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a menu is made of. A bare `MenuItem` is still valid on its
|
|
59
|
+
* own, so existing `items` arrays need no change; a `separator` or a
|
|
60
|
+
* `group` opts in by its `type`.
|
|
61
|
+
*/
|
|
62
|
+
export type MenuEntry = MenuGroup | MenuItem | MenuSeparator
|
|
63
|
+
|
|
26
64
|
export type MenuProps = {
|
|
27
65
|
className?: string
|
|
66
|
+
/**
|
|
67
|
+
* Shown when there is no item to show — a `group` with no members
|
|
68
|
+
* and a bare separator list both count as empty. Rendered as a
|
|
69
|
+
* **disabled** `menuitem` (a `role="menu"` must own one), so it is
|
|
70
|
+
* announced but never focused. Without it an empty menu renders
|
|
71
|
+
* nothing.
|
|
72
|
+
*/
|
|
73
|
+
emptyState?: ReactNode
|
|
28
74
|
isVisible: boolean
|
|
29
|
-
items:
|
|
75
|
+
items: MenuEntry[]
|
|
30
76
|
/** Outside press, Escape, and choosing an item all land here. */
|
|
31
77
|
onDismiss: () => void
|
|
32
78
|
placement?: Placement
|
|
@@ -34,6 +80,16 @@ export type MenuProps = {
|
|
|
34
80
|
trigger: ReactElement
|
|
35
81
|
}
|
|
36
82
|
|
|
83
|
+
const isMenuItem = (entry: MenuEntry): entry is MenuItem =>
|
|
84
|
+
entry.type === undefined || entry.type === "item"
|
|
85
|
+
|
|
86
|
+
const hasAnyItem = (items: MenuEntry[]): boolean =>
|
|
87
|
+
items.some(
|
|
88
|
+
(entry) =>
|
|
89
|
+
isMenuItem(entry) ||
|
|
90
|
+
(entry.type === "group" && entry.items.length > 0),
|
|
91
|
+
)
|
|
92
|
+
|
|
37
93
|
/**
|
|
38
94
|
* A menu is **not** a listbox, and mux-magic's `TypePicker` is the
|
|
39
95
|
* fleet's one attempt at telling them apart — it renders
|
|
@@ -86,6 +142,22 @@ export type MenuProps = {
|
|
|
86
142
|
* focus is whichever one `RovingFocus` says is active, and that is
|
|
87
143
|
* not known until the items have registered.
|
|
88
144
|
*
|
|
145
|
+
* ### Separators, groups, and an empty state
|
|
146
|
+
*
|
|
147
|
+
* `items` is a union, not a flat `MenuItem[]`: a `separator`
|
|
148
|
+
* (`role="separator"`) and a `group` (`role="group"`, named by its
|
|
149
|
+
* `label`) opt in by their `type`, and a bare item still type-checks
|
|
150
|
+
* unchanged. The keyboard model needs no new code for them — a
|
|
151
|
+
* separator and a group heading register nothing, so the roving
|
|
152
|
+
* group never sees them and the arrow keys skip straight over, the
|
|
153
|
+
* same mechanism a disabled item already used. A `group`'s items
|
|
154
|
+
* register normally, so focus moves through them in DOM order as if
|
|
155
|
+
* the heading were not there. When there is nothing to show, the
|
|
156
|
+
* `emptyState` renders as a **disabled** `menuitem` — a `role="menu"`
|
|
157
|
+
* with no `menuitem` child fails `aria-required-children`, so the
|
|
158
|
+
* note has to be one, `aria-disabled` and out of the roving group:
|
|
159
|
+
* announced as "No actions available, dimmed", focusable by nothing.
|
|
160
|
+
*
|
|
89
161
|
* ### No type-ahead, deliberately
|
|
90
162
|
*
|
|
91
163
|
* The APG lists type-ahead as optional for a menu and required for a
|
|
@@ -96,12 +168,15 @@ export type MenuProps = {
|
|
|
96
168
|
*/
|
|
97
169
|
export const Menu = ({
|
|
98
170
|
className,
|
|
171
|
+
emptyState,
|
|
99
172
|
isVisible,
|
|
100
173
|
items,
|
|
101
174
|
onDismiss,
|
|
102
175
|
placement = "bottom-start",
|
|
103
176
|
trigger,
|
|
104
177
|
}: MenuProps): ReactNode => {
|
|
178
|
+
const menuId = useUniqueId()
|
|
179
|
+
|
|
105
180
|
const itemElements = useRef(
|
|
106
181
|
new Map<string, HTMLButtonElement>(),
|
|
107
182
|
)
|
|
@@ -160,6 +235,83 @@ export const Menu = ({
|
|
|
160
235
|
itemElements.current.get(activeValue)?.focus()
|
|
161
236
|
}, [activeValue, isVisible])
|
|
162
237
|
|
|
238
|
+
// One member of the roving group, whether it sits at the top level
|
|
239
|
+
// or inside a `group`. The registration lives in `MenuAction`'s
|
|
240
|
+
// effect, so an item nested in a group joins the same arrow-key
|
|
241
|
+
// sequence in DOM order — the heading above it registers nothing.
|
|
242
|
+
const renderItem = (item: MenuItem) => (
|
|
243
|
+
<MenuAction
|
|
244
|
+
item={item}
|
|
245
|
+
key={item.key}
|
|
246
|
+
onDismiss={onDismiss}
|
|
247
|
+
register={focus.register}
|
|
248
|
+
tabIndex={selectTabIndex(focus, item.key)}
|
|
249
|
+
trackElement={(key, element) => {
|
|
250
|
+
if (element) {
|
|
251
|
+
itemElements.current.set(key, element)
|
|
252
|
+
} else {
|
|
253
|
+
itemElements.current.delete(key)
|
|
254
|
+
}
|
|
255
|
+
}}
|
|
256
|
+
/>
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
const renderEntry = (entry: MenuEntry) => {
|
|
260
|
+
if (entry.type === "separator") {
|
|
261
|
+
// A native `<hr>` — implicit `role="separator"`, and the one
|
|
262
|
+
// that does *not* need `aria-valuenow` (that is the focusable
|
|
263
|
+
// splitter kind, not a static rule between menu groups).
|
|
264
|
+
return (
|
|
265
|
+
<hr
|
|
266
|
+
className="-mx-1 my-1 h-px border-0 bg-border-subtle"
|
|
267
|
+
key={entry.key}
|
|
268
|
+
/>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (entry.type === "group") {
|
|
273
|
+
const headingId = `${menuId}-${entry.key}`
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
// biome-ignore lint/a11y/useSemanticElements: a menu group is `role="group"` (ARIA APG), not a form `<fieldset>` — a fieldset is invalid content inside `role="menu"` and drags legend/border/reset semantics onto it.
|
|
277
|
+
<div
|
|
278
|
+
aria-labelledby={headingId}
|
|
279
|
+
key={entry.key}
|
|
280
|
+
role="group"
|
|
281
|
+
>
|
|
282
|
+
<div
|
|
283
|
+
className="px-2 pt-1.5 pb-0.5 font-medium text-content-secondary text-xs"
|
|
284
|
+
id={headingId}
|
|
285
|
+
>
|
|
286
|
+
{entry.label}
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{entry.items.map(renderItem)}
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return renderItem(entry)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// A **disabled** `menuitem`, not inert text: a `role="menu"` with
|
|
298
|
+
// no `menuitem` child fails `aria-required-children`, so the empty
|
|
299
|
+
// note has to be one — `aria-disabled` and `tabindex="-1"`, so it
|
|
300
|
+
// is announced ("No actions available, dimmed") but takes no tab
|
|
301
|
+
// stop and never joins the roving group. `null` when the consumer
|
|
302
|
+
// gave no `emptyState`, so an empty menu renders nothing.
|
|
303
|
+
const emptyElement =
|
|
304
|
+
emptyState === undefined ? null : (
|
|
305
|
+
<div
|
|
306
|
+
aria-disabled="true"
|
|
307
|
+
className="px-2 py-1.5 text-content-disabled text-sm"
|
|
308
|
+
role="menuitem"
|
|
309
|
+
tabIndex={-1}
|
|
310
|
+
>
|
|
311
|
+
{emptyState}
|
|
312
|
+
</div>
|
|
313
|
+
)
|
|
314
|
+
|
|
163
315
|
return (
|
|
164
316
|
<>
|
|
165
317
|
{clonedTrigger}
|
|
@@ -214,22 +366,9 @@ export const Menu = ({
|
|
|
214
366
|
role="menu"
|
|
215
367
|
style={floatingStyles}
|
|
216
368
|
>
|
|
217
|
-
{items
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
key={item.key}
|
|
221
|
-
onDismiss={onDismiss}
|
|
222
|
-
register={focus.register}
|
|
223
|
-
tabIndex={selectTabIndex(focus, item.key)}
|
|
224
|
-
trackElement={(key, element) => {
|
|
225
|
-
if (element) {
|
|
226
|
-
itemElements.current.set(key, element)
|
|
227
|
-
} else {
|
|
228
|
-
itemElements.current.delete(key)
|
|
229
|
-
}
|
|
230
|
-
}}
|
|
231
|
-
/>
|
|
232
|
-
))}
|
|
369
|
+
{hasAnyItem(items)
|
|
370
|
+
? items.map(renderEntry)
|
|
371
|
+
: emptyElement}
|
|
233
372
|
</div>
|
|
234
373
|
</FloatingFocusManager>
|
|
235
374
|
</FloatingPortal>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as RadioGroupStories from "./RadioGroup.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={RadioGroupStories} />
|
|
6
|
+
|
|
7
|
+
# RadioGroup
|
|
8
|
+
|
|
9
|
+
One choice out of many, each on its own row with a label the size of prose.
|
|
10
|
+
|
|
11
|
+
`RadioGroup` and [`SegmentedControl`](/docs/components-segmentedcontrol--docs) are the
|
|
12
|
+
**same composition** — `SinglePicker` + `RovingFocus`, a `radiogroup` of `radio`s where
|
|
13
|
+
selection follows focus — and a different shape. The segmented strip is compact, for a
|
|
14
|
+
handful of short options that share a control row; the radio group is form-length, where
|
|
15
|
+
each option carries a real sentence and an in-line strip would wrap. Choosing between them
|
|
16
|
+
is layout, not behaviour, which is why the keyboard model is identical and lives in the same
|
|
17
|
+
two hooks.
|
|
18
|
+
|
|
19
|
+
<Canvas of={RadioGroupStories.Default} />
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
<Controls />
|
|
24
|
+
|
|
25
|
+
## `radiogroup`, not a stack of checkboxes
|
|
26
|
+
|
|
27
|
+
A column of `<input type="checkbox">` is the **independent toggles** pattern: nothing in it
|
|
28
|
+
says the options are mutually exclusive, so a screen reader announces each as its own
|
|
29
|
+
on/off with no "1 of 4" and no clue that checking one unchecks the rest. A single choice
|
|
30
|
+
from a named set is a radio group, and ARIA has a pattern for it — which is the same
|
|
31
|
+
correction `SegmentedControl` makes to rip-deck's row of `aria-pressed` buttons.
|
|
32
|
+
|
|
33
|
+
<Canvas of={RadioGroupStories.AllVariants} />
|
|
34
|
+
|
|
35
|
+
## Selection follows focus
|
|
36
|
+
|
|
37
|
+
APG gives a radio group one activation mode: the arrow keys both move focus and check.
|
|
38
|
+
There is nothing behind an option here to be expensive — no panel, no request — so unlike
|
|
39
|
+
`Tabs` there is no `manual` mode and no prop, because a `manual` radio group is one the
|
|
40
|
+
arrow keys appear to do nothing in. Tab enters the group once (the roving-tabindex rule),
|
|
41
|
+
the arrows move and check together, Home and End jump to the ends, and the ends wrap.
|
|
42
|
+
|
|
43
|
+
A disabled option is simply **not registered** with the roving group, so the arrows skip it
|
|
44
|
+
without any command having to know what "disabled" means. It stays registered with the
|
|
45
|
+
picker — it is still one of the options, and still the one a saved `selectedValue` may point
|
|
46
|
+
at.
|
|
47
|
+
|
|
48
|
+
<Canvas of={RadioGroupStories.AllStates} />
|
|
49
|
+
|
|
50
|
+
## `selectedValue` is the initial value, not a controlled one
|
|
51
|
+
|
|
52
|
+
It decides the first render and nothing after — Charcuterie owns the choice from then on,
|
|
53
|
+
and `onChange` reports it outward. Members register from an effect, so on the first paint
|
|
54
|
+
the value lives in the picker's `pendingValue` and the group paints already showing it
|
|
55
|
+
rather than flashing empty for a frame — a flash on a desktop, a visible blank on a kiosk
|
|
56
|
+
Pi.
|
|
57
|
+
|
|
58
|
+
<Canvas of={RadioGroupStories.Interactive} />
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import {
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
StorySection,
|
|
8
|
+
} from "../board.storyHelpers.tsx"
|
|
9
|
+
import type { RadioItem } from "./RadioGroup.tsx"
|
|
10
|
+
import { RadioGroup } from "./RadioGroup.tsx"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options carrying a real sentence each — the case an in-line
|
|
14
|
+
* `SegmentedControl` strip would wrap, and the reason to stack.
|
|
15
|
+
*/
|
|
16
|
+
const NAMING_ITEMS: RadioItem[] = [
|
|
17
|
+
{ label: "Match the Plex agent", value: "plex" },
|
|
18
|
+
{ label: "Match AniDB titles", value: "anidb" },
|
|
19
|
+
{
|
|
20
|
+
label: "Keep the original filenames",
|
|
21
|
+
value: "original",
|
|
22
|
+
},
|
|
23
|
+
{ label: "Use a custom pattern", value: "custom" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: "Components/RadioGroup",
|
|
28
|
+
component: RadioGroup,
|
|
29
|
+
parameters: { layout: "padded" },
|
|
30
|
+
argTypes: { size: controlSizeArgType },
|
|
31
|
+
// The component's own defaults, restated — Storybook does not seed
|
|
32
|
+
// `args` from docgen, so an unstated default shows in the props
|
|
33
|
+
// table with nothing selected in its control.
|
|
34
|
+
args: { size: "md" },
|
|
35
|
+
} satisfies Meta<typeof RadioGroup>
|
|
36
|
+
|
|
37
|
+
export default meta
|
|
38
|
+
|
|
39
|
+
type Story = StoryObj<typeof meta>
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {
|
|
42
|
+
args: { items: NAMING_ITEMS, label: "Naming scheme" },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const AllVariants: Story = {
|
|
46
|
+
args: { items: NAMING_ITEMS, label: "Naming scheme" },
|
|
47
|
+
render: (controlProps) => (
|
|
48
|
+
<StorySection title="Three control heights, all reading the density axis rather than hardcoding a length.">
|
|
49
|
+
<StoryGrid columns={3}>
|
|
50
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
51
|
+
<StoryCell key={size} label={size}>
|
|
52
|
+
<RadioGroup
|
|
53
|
+
{...controlProps}
|
|
54
|
+
label={`Naming scheme at ${size}`}
|
|
55
|
+
size={size}
|
|
56
|
+
/>
|
|
57
|
+
</StoryCell>
|
|
58
|
+
))}
|
|
59
|
+
</StoryGrid>
|
|
60
|
+
</StorySection>
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const STATE_ITEMS: RadioItem[] = [
|
|
65
|
+
{ label: "Match the Plex agent", value: "plex" },
|
|
66
|
+
{ label: "Match AniDB titles", value: "anidb" },
|
|
67
|
+
{
|
|
68
|
+
isDisabled: true,
|
|
69
|
+
label: "Keep the original filenames",
|
|
70
|
+
value: "original",
|
|
71
|
+
},
|
|
72
|
+
{ label: "Use a custom pattern", value: "custom" },
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A disabled option is out of the *focus* group and still one of the
|
|
77
|
+
* *options* — it can even be the one a consumer names as the initial
|
|
78
|
+
* `selectedValue`, which is what a saved preference pointing at a
|
|
79
|
+
* scheme the source no longer supports looks like.
|
|
80
|
+
*/
|
|
81
|
+
export const AllStates: Story = {
|
|
82
|
+
args: { items: STATE_ITEMS, label: "Naming scheme" },
|
|
83
|
+
render: (controlProps) => (
|
|
84
|
+
<StorySection title="What varies is which option is checked and which are reachable.">
|
|
85
|
+
<StoryGrid columns={2}>
|
|
86
|
+
<StoryCell label="first option checked">
|
|
87
|
+
<RadioGroup
|
|
88
|
+
{...controlProps}
|
|
89
|
+
label="Naming scheme starting on Plex"
|
|
90
|
+
/>
|
|
91
|
+
</StoryCell>
|
|
92
|
+
|
|
93
|
+
<StoryCell label="starts on the fourth option">
|
|
94
|
+
<RadioGroup
|
|
95
|
+
{...controlProps}
|
|
96
|
+
label="Naming scheme starting on custom"
|
|
97
|
+
selectedValue="custom"
|
|
98
|
+
/>
|
|
99
|
+
</StoryCell>
|
|
100
|
+
|
|
101
|
+
<StoryCell label="a disabled option">
|
|
102
|
+
<RadioGroup
|
|
103
|
+
{...controlProps}
|
|
104
|
+
label="Naming scheme with original unavailable"
|
|
105
|
+
/>
|
|
106
|
+
</StoryCell>
|
|
107
|
+
|
|
108
|
+
<StoryCell label="two options">
|
|
109
|
+
<RadioGroup
|
|
110
|
+
{...controlProps}
|
|
111
|
+
items={[
|
|
112
|
+
{ label: "Move the files", value: "move" },
|
|
113
|
+
{ label: "Copy the files", value: "copy" },
|
|
114
|
+
]}
|
|
115
|
+
label="On import"
|
|
116
|
+
/>
|
|
117
|
+
</StoryCell>
|
|
118
|
+
</StoryGrid>
|
|
119
|
+
</StorySection>
|
|
120
|
+
),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The complete keyboard path. Tab enters the group **once** — the
|
|
125
|
+
* roving-tabindex rule — then the arrow keys move and check
|
|
126
|
+
* together, the same activation model as `SegmentedControl`.
|
|
127
|
+
*/
|
|
128
|
+
export const Interactive: Story = {
|
|
129
|
+
args: { items: NAMING_ITEMS, label: "Naming scheme" },
|
|
130
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
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 "./RadioGroup.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, Default, Interactive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `querySelectorAll` rather than `getAllByRole("radio")`: a board
|
|
15
|
+
* renders several groups, and "exactly one tab stop" is only the
|
|
16
|
+
* roving rule when it is scoped to **one** group.
|
|
17
|
+
*/
|
|
18
|
+
const getOptions = (group: HTMLElement) =>
|
|
19
|
+
Array.from(
|
|
20
|
+
group.querySelectorAll<HTMLButtonElement>(
|
|
21
|
+
'[role="radio"]',
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
test("it is a radio group with exactly one option checked", async () => {
|
|
26
|
+
const { canvas, canvasElement } =
|
|
27
|
+
await mountStory(Default)
|
|
28
|
+
|
|
29
|
+
const group = expectAgentDrivable(canvas, {
|
|
30
|
+
name: "Naming scheme",
|
|
31
|
+
role: "radiogroup",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const options = getOptions(group)
|
|
35
|
+
|
|
36
|
+
await expect(options).toHaveLength(4)
|
|
37
|
+
|
|
38
|
+
await waitFor(() => {
|
|
39
|
+
expect(
|
|
40
|
+
options.filter(
|
|
41
|
+
(one) =>
|
|
42
|
+
one.getAttribute("aria-checked") === "true",
|
|
43
|
+
),
|
|
44
|
+
).toHaveLength(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await expectNoAxeViolations(canvasElement)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("Tab enters the group once, and the arrows move within it", async () => {
|
|
51
|
+
const { canvas } = await mountStory(Interactive)
|
|
52
|
+
|
|
53
|
+
const options = getOptions(
|
|
54
|
+
expectAgentDrivable(canvas, {
|
|
55
|
+
name: "Naming scheme",
|
|
56
|
+
role: "radiogroup",
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Exactly one tab stop is the roving rule, read straight out of
|
|
61
|
+
// `selectTabIndex`. Zero strands the widget; several mean the
|
|
62
|
+
// pattern was never implemented.
|
|
63
|
+
await expect(
|
|
64
|
+
options.filter((one) => one.tabIndex === 0),
|
|
65
|
+
).toHaveLength(1)
|
|
66
|
+
|
|
67
|
+
await userEvent.tab()
|
|
68
|
+
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(options[0]).toHaveFocus()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
74
|
+
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(options[1]).toHaveFocus()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("selection follows focus, and wraps", async () => {
|
|
81
|
+
const { canvas } = await mountStory(Interactive)
|
|
82
|
+
|
|
83
|
+
const options = getOptions(
|
|
84
|
+
expectAgentDrivable(canvas, {
|
|
85
|
+
name: "Naming scheme",
|
|
86
|
+
role: "radiogroup",
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
options[0]?.focus()
|
|
91
|
+
|
|
92
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(options[1]).toHaveAttribute(
|
|
96
|
+
"aria-checked",
|
|
97
|
+
"true",
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await expect(options[0]).toHaveAttribute(
|
|
102
|
+
"aria-checked",
|
|
103
|
+
"false",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// End then wrap — both from `RovingFocus`, neither written in the
|
|
107
|
+
// component.
|
|
108
|
+
await userEvent.keyboard("{End}")
|
|
109
|
+
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
expect(options[3]).toHaveAttribute(
|
|
112
|
+
"aria-checked",
|
|
113
|
+
"true",
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(options[0]).toHaveAttribute(
|
|
121
|
+
"aria-checked",
|
|
122
|
+
"true",
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("a pointer press checks the option it landed on", async () => {
|
|
128
|
+
const { canvas } = await mountStory(Interactive)
|
|
129
|
+
|
|
130
|
+
const custom = expectAgentDrivable(canvas, {
|
|
131
|
+
name: "Use a custom pattern",
|
|
132
|
+
role: "radio",
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await userEvent.click(custom)
|
|
136
|
+
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(custom).toHaveAttribute("aria-checked", "true")
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test("the arrow keys skip a disabled option", async () => {
|
|
143
|
+
const { canvas, canvasElement } =
|
|
144
|
+
await mountStory(AllStates)
|
|
145
|
+
|
|
146
|
+
const group = expectAgentDrivable(canvas, {
|
|
147
|
+
name: "Naming scheme with original unavailable",
|
|
148
|
+
role: "radiogroup",
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const options = getOptions(group)
|
|
152
|
+
|
|
153
|
+
const [first, , original, custom] = options as [
|
|
154
|
+
HTMLButtonElement,
|
|
155
|
+
HTMLButtonElement,
|
|
156
|
+
HTMLButtonElement,
|
|
157
|
+
HTMLButtonElement,
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
await expect(original).toBeDisabled()
|
|
161
|
+
|
|
162
|
+
first.focus()
|
|
163
|
+
|
|
164
|
+
await userEvent.keyboard("{ArrowDown}{ArrowDown}")
|
|
165
|
+
|
|
166
|
+
// Two presses from the first option lands on `custom`, skipping
|
|
167
|
+
// the disabled `original` — registration is membership, and a
|
|
168
|
+
// disabled option never joined the focus group.
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(custom).toHaveFocus()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
await expect(original).not.toHaveFocus()
|
|
174
|
+
|
|
175
|
+
await expectNoAxeViolations(canvasElement)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test("selectedValue decides the first render and nothing after", async () => {
|
|
179
|
+
const { canvas } = await mountStory(AllStates)
|
|
180
|
+
|
|
181
|
+
const group = expectAgentDrivable(canvas, {
|
|
182
|
+
name: "Naming scheme starting on custom",
|
|
183
|
+
role: "radiogroup",
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(
|
|
188
|
+
group.querySelector('[aria-checked="true"]'),
|
|
189
|
+
).toHaveTextContent("Use a custom pattern")
|
|
190
|
+
})
|
|
191
|
+
})
|