@charcuterie/ui 2.4.1 → 2.6.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 +68 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/Checkbox/Checkbox.js +108 -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 +54 -0
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -0
- package/dist/RadioGroup/RadioGroup.js +121 -0
- package/dist/RadioGroup/RadioGroup.js.map +1 -0
- package/dist/RadioGroup/RadioGroupOption.d.ts +27 -0
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -0
- package/dist/RadioGroup/RadioGroupOption.js +75 -0
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -0
- package/dist/Switch/Switch.d.ts +60 -0
- package/dist/Switch/Switch.d.ts.map +1 -0
- package/dist/Switch/Switch.js +106 -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 +71 -0
- package/src/Checkbox/Checkbox.stories.tsx +122 -0
- package/src/Checkbox/Checkbox.test.tsx +109 -0
- package/src/Checkbox/Checkbox.tsx +205 -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 +67 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +140 -0
- package/src/RadioGroup/RadioGroup.test.tsx +232 -0
- package/src/RadioGroup/RadioGroup.tsx +223 -0
- package/src/RadioGroup/RadioGroupOption.tsx +148 -0
- package/src/Switch/Switch.mdx +55 -0
- package/src/Switch/Switch.stories.tsx +116 -0
- package/src/Switch/Switch.test.tsx +115 -0
- package/src/Switch/Switch.tsx +183 -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.test.tsx
CHANGED
|
@@ -10,6 +10,8 @@ import * as stories from "./Menu.stories.tsx"
|
|
|
10
10
|
const {
|
|
11
11
|
AllStates,
|
|
12
12
|
AllVariants,
|
|
13
|
+
Empty,
|
|
14
|
+
Grouped,
|
|
13
15
|
Interactive,
|
|
14
16
|
SharedTrigger,
|
|
15
17
|
} = composeStories(stories)
|
|
@@ -320,3 +322,103 @@ test("the tooltip sharing that trigger is anchored to it too", async () => {
|
|
|
320
322
|
)
|
|
321
323
|
})
|
|
322
324
|
})
|
|
325
|
+
|
|
326
|
+
test("a group is a named group and a separator is a separator", async () => {
|
|
327
|
+
const { body } = await mountStory(Grouped)
|
|
328
|
+
|
|
329
|
+
const menu = expectAgentDrivable(body, {
|
|
330
|
+
name: "Bay 6",
|
|
331
|
+
role: "menu",
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// Each group is a `role="group"` named by its heading, so a screen
|
|
335
|
+
// reader announces "Danger, group" before its members.
|
|
336
|
+
expectAgentDrivable(body, {
|
|
337
|
+
name: "Disc",
|
|
338
|
+
role: "group",
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
expectAgentDrivable(body, {
|
|
342
|
+
name: "Danger",
|
|
343
|
+
role: "group",
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// A native `<hr>`, which carries the implicit `role="separator"`.
|
|
347
|
+
await expect(menu.querySelectorAll("hr")).toHaveLength(1)
|
|
348
|
+
|
|
349
|
+
await expect(body.getAllByRole("separator")).toHaveLength(
|
|
350
|
+
1,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
await expectNoAxeViolations(menu)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test("arrow keys skip group headings and separators", async () => {
|
|
357
|
+
const { body } = await mountStory(Grouped)
|
|
358
|
+
|
|
359
|
+
const retry = expectAgentDrivable(body, {
|
|
360
|
+
name: "Retry title",
|
|
361
|
+
role: "menuitem",
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
const skip = expectAgentDrivable(body, {
|
|
365
|
+
name: "Skip title",
|
|
366
|
+
role: "menuitem",
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
const eject = expectAgentDrivable(body, {
|
|
370
|
+
name: "Eject disc",
|
|
371
|
+
role: "menuitem",
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// Opening lands on the first item, and arrow-down walks the items
|
|
375
|
+
// in DOM order across the group boundary — the heading and the
|
|
376
|
+
// separator register nothing, so there is nothing between Skip
|
|
377
|
+
// (end of group one) and Eject (start of group two) to stop on.
|
|
378
|
+
await waitFor(() => {
|
|
379
|
+
expect(retry).toHaveFocus()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
383
|
+
|
|
384
|
+
await waitFor(() => {
|
|
385
|
+
expect(skip).toHaveFocus()
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
await userEvent.keyboard("{ArrowDown}")
|
|
389
|
+
|
|
390
|
+
await waitFor(() => {
|
|
391
|
+
expect(eject).toHaveFocus()
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test("an empty menu shows a disabled note, not an actionable item", async () => {
|
|
396
|
+
const { body } = await mountStory(Empty)
|
|
397
|
+
|
|
398
|
+
const menu = expectAgentDrivable(body, {
|
|
399
|
+
name: "Bay 7",
|
|
400
|
+
role: "menu",
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
await expect(menu).toHaveTextContent(
|
|
404
|
+
"No actions available",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
// The note is the menu's one `menuitem` — a `role="menu"` must own
|
|
408
|
+
// one — but it is `aria-disabled` and carries no tab stop, so a
|
|
409
|
+
// keyboard user is told the menu is empty and cannot land on it.
|
|
410
|
+
const items = menu.querySelectorAll<HTMLElement>(
|
|
411
|
+
'[role="menuitem"]',
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
await expect(items).toHaveLength(1)
|
|
415
|
+
|
|
416
|
+
await expect(items[0]).toHaveAttribute(
|
|
417
|
+
"aria-disabled",
|
|
418
|
+
"true",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
await expect(items[0]?.tabIndex).toBe(-1)
|
|
422
|
+
|
|
423
|
+
await expectNoAxeViolations(menu)
|
|
424
|
+
})
|
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,67 @@
|
|
|
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
|
+
## A read-only group shows the choice without moving it
|
|
51
|
+
|
|
52
|
+
`isReadOnly` severs the one link that defines this component — selection follows focus — so
|
|
53
|
+
the arrow keys and clicks still travel the options for reading but no longer move the
|
|
54
|
+
choice. The group announces `aria-readonly`, and the chosen option shows at full contrast
|
|
55
|
+
in the **neutral** intent: a value you may read here and change elsewhere. That is distinct
|
|
56
|
+
from a single **disabled** option, which is dimmed with `opacity-60` and dropped from the
|
|
57
|
+
focus group but stays selectable via `selectedValue`.
|
|
58
|
+
|
|
59
|
+
## `selectedValue` is the initial value, not a controlled one
|
|
60
|
+
|
|
61
|
+
It decides the first render and nothing after — Charcuterie owns the choice from then on,
|
|
62
|
+
and `onChange` reports it outward. Members register from an effect, so on the first paint
|
|
63
|
+
the value lives in the picker's `pendingValue` and the group paints already showing it
|
|
64
|
+
rather than flashing empty for a frame — a flash on a desktop, a visible blank on a kiosk
|
|
65
|
+
Pi.
|
|
66
|
+
|
|
67
|
+
<Canvas of={RadioGroupStories.Interactive} />
|
|
@@ -0,0 +1,140 @@
|
|
|
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: { isReadOnly: false, 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
|
+
|
|
119
|
+
<StoryCell label="read-only">
|
|
120
|
+
<RadioGroup
|
|
121
|
+
{...controlProps}
|
|
122
|
+
isReadOnly
|
|
123
|
+
items={NAMING_ITEMS}
|
|
124
|
+
label="Naming scheme, read-only"
|
|
125
|
+
selectedValue="anidb"
|
|
126
|
+
/>
|
|
127
|
+
</StoryCell>
|
|
128
|
+
</StoryGrid>
|
|
129
|
+
</StorySection>
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The complete keyboard path. Tab enters the group **once** — the
|
|
135
|
+
* roving-tabindex rule — then the arrow keys move and check
|
|
136
|
+
* together, the same activation model as `SegmentedControl`.
|
|
137
|
+
*/
|
|
138
|
+
export const Interactive: Story = {
|
|
139
|
+
args: { items: NAMING_ITEMS, label: "Naming scheme" },
|
|
140
|
+
}
|