@charcuterie/ui 2.4.0 → 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/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +1 -2
- package/dist/Combobox/Combobox.js.map +1 -1
- 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 +3 -3
- 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 +64 -4
- package/src/Combobox/Combobox.tsx +13 -1
- 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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
selectTabIndex,
|
|
3
|
+
useRovingFocus,
|
|
4
|
+
useSinglePicker,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
6
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
7
|
+
import type { ReactNode } from "react"
|
|
8
|
+
import { useCallback, useEffect, useRef } from "react"
|
|
9
|
+
|
|
10
|
+
import { toClassName } from "../toClassName.ts"
|
|
11
|
+
import { RadioGroupOption } from "./RadioGroupOption.tsx"
|
|
12
|
+
|
|
13
|
+
export type RadioItem = {
|
|
14
|
+
/**
|
|
15
|
+
* A disabled option is simply **not registered** with the roving
|
|
16
|
+
* group, so the arrow keys skip it without any command in
|
|
17
|
+
* `RovingFocus` having to know what "disabled" means. It stays
|
|
18
|
+
* registered with the picker: it is still one of the options.
|
|
19
|
+
*/
|
|
20
|
+
isDisabled?: boolean
|
|
21
|
+
label: ReactNode
|
|
22
|
+
value: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RadioGroupProps = {
|
|
26
|
+
className?: string
|
|
27
|
+
items: readonly RadioItem[]
|
|
28
|
+
/** The group's accessible name. Required. */
|
|
29
|
+
label: string
|
|
30
|
+
onChange?: (selectedValue: string | null) => void
|
|
31
|
+
/** **Initial** only. Charcuterie owns it from then on. */
|
|
32
|
+
selectedValue?: string
|
|
33
|
+
size?: ControlSize
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const GAP_CLASS: Record<ControlSize, string> = {
|
|
37
|
+
sm: "gap-1",
|
|
38
|
+
md: "gap-1.5",
|
|
39
|
+
lg: "gap-2",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One choice out of many, each on its own row with a label the size
|
|
44
|
+
* of prose — the stacked sibling of `SegmentedControl`.
|
|
45
|
+
*
|
|
46
|
+
* The two are the *same* composition — `SinglePicker` + `RovingFocus`,
|
|
47
|
+
* a `radiogroup` of `radio`s where selection follows focus — and a
|
|
48
|
+
* different shape. `SegmentedControl` is a compact strip for a
|
|
49
|
+
* handful of short options that share a control row; `RadioGroup` is
|
|
50
|
+
* the form-length list where each option carries a real sentence and
|
|
51
|
+
* an in-line strip would wrap. Picking between them is layout, not
|
|
52
|
+
* behaviour, which is why the keyboard model here is identical and
|
|
53
|
+
* lives in the same two hooks.
|
|
54
|
+
*
|
|
55
|
+
* ### `radiogroup`, not a stack of checkboxes
|
|
56
|
+
*
|
|
57
|
+
* A column of `<input type="checkbox">` is the **independent
|
|
58
|
+
* toggles** pattern: nothing says the options are mutually
|
|
59
|
+
* exclusive, and a screen reader never announces "1 of 4". A single
|
|
60
|
+
* choice from a named set is a radio group, and its arrow keys both
|
|
61
|
+
* move focus and check — APG's one activation mode, the same reason
|
|
62
|
+
* `SegmentedControl` has no `manual` prop.
|
|
63
|
+
*/
|
|
64
|
+
export const RadioGroup = ({
|
|
65
|
+
className,
|
|
66
|
+
items,
|
|
67
|
+
label,
|
|
68
|
+
onChange,
|
|
69
|
+
selectedValue,
|
|
70
|
+
size = "md",
|
|
71
|
+
}: RadioGroupProps): ReactNode => {
|
|
72
|
+
const groupRef = useRef<HTMLDivElement>(null)
|
|
73
|
+
|
|
74
|
+
const optionElements = useRef(
|
|
75
|
+
new Map<string, HTMLButtonElement>(),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const initialValue =
|
|
79
|
+
selectedValue ??
|
|
80
|
+
items.find((one) => !one.isDisabled)?.value ??
|
|
81
|
+
null
|
|
82
|
+
|
|
83
|
+
const selection = useSinglePicker<string>({
|
|
84
|
+
onChange,
|
|
85
|
+
selectedValue: initialValue,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const focus = useRovingFocus<string>({
|
|
89
|
+
activeValue: initialValue,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const { select } = selection
|
|
93
|
+
|
|
94
|
+
const { activeValue, setActiveValue } = focus
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Selected **or** pending. Members register from an effect, so on
|
|
98
|
+
* the first paint `selectedValue` is still null and the intent
|
|
99
|
+
* lives in `pendingValue` — reading only the former renders a
|
|
100
|
+
* group with nothing checked, then corrects itself a frame later.
|
|
101
|
+
*/
|
|
102
|
+
const checkedValue =
|
|
103
|
+
selection.selectedValue ?? selection.pendingValue
|
|
104
|
+
|
|
105
|
+
const chooseOption = useCallback(
|
|
106
|
+
(value: string) => {
|
|
107
|
+
setActiveValue(value)
|
|
108
|
+
|
|
109
|
+
select(value)
|
|
110
|
+
},
|
|
111
|
+
[select, setActiveValue],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
// Selection follows focus — the radio-group rule, and the one
|
|
116
|
+
// line a `manual` mode this component deliberately lacks would
|
|
117
|
+
// remove.
|
|
118
|
+
if (activeValue !== null) {
|
|
119
|
+
select(activeValue)
|
|
120
|
+
}
|
|
121
|
+
}, [activeValue, select])
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const group = groupRef.current
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
!group ||
|
|
128
|
+
activeValue === null ||
|
|
129
|
+
!group.contains(document.activeElement)
|
|
130
|
+
) {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
optionElements.current.get(activeValue)?.focus()
|
|
135
|
+
}, [activeValue])
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
aria-label={label}
|
|
140
|
+
className={toClassName(
|
|
141
|
+
"flex flex-col",
|
|
142
|
+
GAP_CLASS[size],
|
|
143
|
+
className,
|
|
144
|
+
)}
|
|
145
|
+
onKeyDown={(keyEvent) => {
|
|
146
|
+
const commands: Record<string, () => void> = {
|
|
147
|
+
ArrowDown: focus.next,
|
|
148
|
+
ArrowLeft: focus.previous,
|
|
149
|
+
ArrowRight: focus.next,
|
|
150
|
+
ArrowUp: focus.previous,
|
|
151
|
+
End: focus.last,
|
|
152
|
+
Home: focus.first,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const command = commands[keyEvent.key]
|
|
156
|
+
|
|
157
|
+
if (command) {
|
|
158
|
+
// Arrow keys scroll the page by default, and a control
|
|
159
|
+
// that scrolls the window while it moves focus is the
|
|
160
|
+
// hand-rolled version of this component.
|
|
161
|
+
keyEvent.preventDefault()
|
|
162
|
+
|
|
163
|
+
command()
|
|
164
|
+
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Space checks the focused option. Selection already follows
|
|
169
|
+
// focus, so this is almost always a no-op — one idempotent
|
|
170
|
+
// command, not a second code path.
|
|
171
|
+
if (keyEvent.key === " " && activeValue !== null) {
|
|
172
|
+
keyEvent.preventDefault()
|
|
173
|
+
|
|
174
|
+
select(activeValue)
|
|
175
|
+
}
|
|
176
|
+
}}
|
|
177
|
+
ref={groupRef}
|
|
178
|
+
role="radiogroup"
|
|
179
|
+
>
|
|
180
|
+
{items.map((item) => (
|
|
181
|
+
<RadioGroupOption
|
|
182
|
+
isChecked={checkedValue === item.value}
|
|
183
|
+
item={item}
|
|
184
|
+
key={item.value}
|
|
185
|
+
onChoose={chooseOption}
|
|
186
|
+
registerFocus={focus.register}
|
|
187
|
+
registerSelection={selection.register}
|
|
188
|
+
size={size}
|
|
189
|
+
// The roving-tabindex rule read from the core rather than
|
|
190
|
+
// restated here: exactly one option is in the tab order, so
|
|
191
|
+
// Tab enters and leaves the group once.
|
|
192
|
+
tabIndex={selectTabIndex(focus, item.value)}
|
|
193
|
+
trackElement={(value, element) => {
|
|
194
|
+
if (element) {
|
|
195
|
+
optionElements.current.set(value, element)
|
|
196
|
+
} else {
|
|
197
|
+
optionElements.current.delete(value)
|
|
198
|
+
}
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import { useEffect } from "react"
|
|
4
|
+
|
|
5
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
6
|
+
import { toClassName } from "../toClassName.ts"
|
|
7
|
+
import type { RadioItem } from "./RadioGroup.tsx"
|
|
8
|
+
|
|
9
|
+
export type RadioGroupOptionProps = {
|
|
10
|
+
isChecked: boolean
|
|
11
|
+
item: RadioItem
|
|
12
|
+
onChoose: (value: string) => void
|
|
13
|
+
/** `RovingFocus.register` — membership of the arrow-key group. */
|
|
14
|
+
registerFocus: (value: string) => () => void
|
|
15
|
+
/** `SinglePicker.register` — membership of the choice. */
|
|
16
|
+
registerSelection: (value: string) => () => void
|
|
17
|
+
size: ControlSize
|
|
18
|
+
tabIndex: number
|
|
19
|
+
trackElement: (
|
|
20
|
+
value: string,
|
|
21
|
+
element: HTMLButtonElement | null,
|
|
22
|
+
) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RING_SIZE_CLASS: Record<ControlSize, string> = {
|
|
26
|
+
sm: "size-3.5",
|
|
27
|
+
md: "size-4",
|
|
28
|
+
lg: "size-5",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DOT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
32
|
+
sm: "size-1.5",
|
|
33
|
+
md: "size-2",
|
|
34
|
+
lg: "size-2.5",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
38
|
+
sm: "text-sm",
|
|
39
|
+
md: "text-md",
|
|
40
|
+
lg: "text-lg",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Its own file for the same reason `SegmentedOption` is: **both
|
|
45
|
+
* registrations are effects**, and an effect cannot run in a loop.
|
|
46
|
+
*
|
|
47
|
+
* The ring and the dot are two elements rather than a bordered box
|
|
48
|
+
* with a background, so the checked dot can be centred by the grid
|
|
49
|
+
* and sized independently of the ring — a filled `bg-intent-accent`
|
|
50
|
+
* ring with no inner dot reads as a checkbox, not a radio.
|
|
51
|
+
*/
|
|
52
|
+
export const RadioGroupOption = ({
|
|
53
|
+
isChecked,
|
|
54
|
+
item,
|
|
55
|
+
onChoose,
|
|
56
|
+
registerFocus,
|
|
57
|
+
registerSelection,
|
|
58
|
+
size,
|
|
59
|
+
tabIndex,
|
|
60
|
+
trackElement,
|
|
61
|
+
}: RadioGroupOptionProps): ReactNode => {
|
|
62
|
+
const { isDisabled = false, label, value } = item
|
|
63
|
+
|
|
64
|
+
useEffect(
|
|
65
|
+
() => registerSelection(value),
|
|
66
|
+
[registerSelection, value],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isDisabled) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return registerFocus(value)
|
|
75
|
+
}, [isDisabled, registerFocus, value])
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
// biome-ignore lint/a11y/useSemanticElements: `<input type="radio">` owns `checked` *and* owns arrow-key navigation within a `name` group — the same self-owning-control conflict `SegmentedOption` documents, where the browser and `SinglePicker` would each believe they hold the choice and native roving would fight `RovingFocus`. APG's radio-group pattern is built for exactly this.
|
|
79
|
+
<button
|
|
80
|
+
aria-checked={isChecked}
|
|
81
|
+
className={toClassName(
|
|
82
|
+
"inline-flex cursor-pointer items-center gap-2 rounded-sm text-start transition-colors duration-(--duration-fast) ease-standard",
|
|
83
|
+
TEXT_SIZE_CLASS[size],
|
|
84
|
+
isDisabled
|
|
85
|
+
? "cursor-not-allowed text-content-disabled"
|
|
86
|
+
: "text-content-secondary hover:text-content-primary",
|
|
87
|
+
FOCUS_RING_CLASS,
|
|
88
|
+
)}
|
|
89
|
+
disabled={isDisabled}
|
|
90
|
+
onClick={() => {
|
|
91
|
+
onChoose(value)
|
|
92
|
+
}}
|
|
93
|
+
ref={(element) => {
|
|
94
|
+
trackElement(value, element)
|
|
95
|
+
}}
|
|
96
|
+
role="radio"
|
|
97
|
+
tabIndex={tabIndex}
|
|
98
|
+
type="button"
|
|
99
|
+
>
|
|
100
|
+
<span
|
|
101
|
+
className={toClassName(
|
|
102
|
+
"grid shrink-0 place-items-center rounded-full border transition-colors duration-(--duration-fast) ease-standard",
|
|
103
|
+
isDisabled
|
|
104
|
+
? "border-border-subtle"
|
|
105
|
+
: isChecked
|
|
106
|
+
? "border-intent-accent-solid"
|
|
107
|
+
: "border-border-strong",
|
|
108
|
+
RING_SIZE_CLASS[size],
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
<span
|
|
112
|
+
className={toClassName(
|
|
113
|
+
"rounded-full transition-transform duration-(--duration-fast) ease-standard",
|
|
114
|
+
isChecked ? "scale-100" : "scale-0",
|
|
115
|
+
isDisabled
|
|
116
|
+
? "bg-content-disabled"
|
|
117
|
+
: "bg-intent-accent-solid",
|
|
118
|
+
DOT_SIZE_CLASS[size],
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
</span>
|
|
122
|
+
|
|
123
|
+
<span>{label}</span>
|
|
124
|
+
</button>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as SwitchStories from "./Switch.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={SwitchStories} />
|
|
6
|
+
|
|
7
|
+
# Switch
|
|
8
|
+
|
|
9
|
+
A boolean that takes effect the moment it is flipped — a setting, not a form value.
|
|
10
|
+
|
|
11
|
+
A `Switch` and a [`Checkbox`](/docs/components-checkbox--docs) are the **same state kind and
|
|
12
|
+
a different affordance**, and the split is meaning rather than looks. A checkbox is a value
|
|
13
|
+
you submit — "delete originals", "I agree". A switch is a state you toggle and live with
|
|
14
|
+
immediately — "automatic imports on". ARIA gives the second its own role, and a screen
|
|
15
|
+
reader announces "on/off" for a `switch` where it announces "checked/unchecked" for a
|
|
16
|
+
checkbox.
|
|
17
|
+
|
|
18
|
+
<Canvas of={SwitchStories.Default} />
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
<Controls />
|
|
23
|
+
|
|
24
|
+
## A `button role="switch"`, not an `input`
|
|
25
|
+
|
|
26
|
+
The sliding track and thumb are the whole affordance, and a `<button>` styles them without
|
|
27
|
+
fighting a native control's own rendering. It carries `aria-checked` and `aria-labelledby`
|
|
28
|
+
and nothing else the browser would try to own, so its keyboard path is the button's: Tab
|
|
29
|
+
reaches it, Space and Enter flip it.
|
|
30
|
+
|
|
31
|
+
The value lives in React state seeded once from `isChecked` — the switch is the store, the
|
|
32
|
+
same way the DOM is for `Checkbox`, and the same refusal of a controlled prop.
|
|
33
|
+
|
|
34
|
+
<Canvas of={SwitchStories.AllVariants} />
|
|
35
|
+
|
|
36
|
+
## The thumb changes colour, not just position
|
|
37
|
+
|
|
38
|
+
On `bg-intent-accent-solid`, off on `bg-surface-sunken`, and the thumb picks the token that
|
|
39
|
+
contrasts with whichever it is sitting on. A switch whose only signal was the thumb's
|
|
40
|
+
*position* loses that signal to a `daylight` theme in sunlight or to a colour-blind reader;
|
|
41
|
+
a filled track plus a contrasting thumb survives both, which is the same reason
|
|
42
|
+
`SegmentedControl` fills its checked option rather than tinting it.
|
|
43
|
+
|
|
44
|
+
<Canvas of={SwitchStories.AllStates} />
|
|
45
|
+
|
|
46
|
+
## One that is off is still reachable
|
|
47
|
+
|
|
48
|
+
`disabled` removes it from the tab order and the pointer, and `aria-disabled` is left to the
|
|
49
|
+
native `disabled` the `<button>` already carries. A disabled-and-on switch is a setting the
|
|
50
|
+
user cannot currently change — a policy forced from above, say — not a broken control, and
|
|
51
|
+
it reads that way to a screen reader.
|
|
52
|
+
|
|
53
|
+
<Canvas of={SwitchStories.Interactive} />
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { Switch } from "./Switch.tsx"
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: "Components/Switch",
|
|
13
|
+
component: Switch,
|
|
14
|
+
parameters: { layout: "padded" },
|
|
15
|
+
argTypes: { size: controlSizeArgType },
|
|
16
|
+
// The component's own defaults, restated — Storybook does not seed
|
|
17
|
+
// `args` from docgen, so an unstated default shows in the props
|
|
18
|
+
// table with nothing selected in its control.
|
|
19
|
+
args: { isChecked: false, isDisabled: false, size: "md" },
|
|
20
|
+
} satisfies Meta<typeof Switch>
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
|
|
24
|
+
type Story = StoryObj<typeof meta>
|
|
25
|
+
|
|
26
|
+
export const Default: Story = {
|
|
27
|
+
args: { label: "Automatic imports" },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const AllVariants: Story = {
|
|
31
|
+
args: { label: "Automatic imports" },
|
|
32
|
+
render: (controlProps) => (
|
|
33
|
+
<StorySection title="Three control heights, all reading the density axis rather than hardcoding a length.">
|
|
34
|
+
<StoryGrid columns={3}>
|
|
35
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
36
|
+
<StoryCell key={size} label={size}>
|
|
37
|
+
<Switch
|
|
38
|
+
{...controlProps}
|
|
39
|
+
isChecked
|
|
40
|
+
label={`On at ${size}`}
|
|
41
|
+
size={size}
|
|
42
|
+
/>
|
|
43
|
+
</StoryCell>
|
|
44
|
+
))}
|
|
45
|
+
</StoryGrid>
|
|
46
|
+
</StorySection>
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The thumb changes colour as well as position, so the state reads at
|
|
52
|
+
* a glance in `daylight` sunlight — a thumb that only moved would not.
|
|
53
|
+
*/
|
|
54
|
+
export const AllStates: Story = {
|
|
55
|
+
args: { label: "Automatic imports" },
|
|
56
|
+
render: (controlProps) => (
|
|
57
|
+
<StorySection title="What varies is whether the switch is on and whether it can be changed.">
|
|
58
|
+
<StoryGrid columns={2}>
|
|
59
|
+
<StoryCell label="off">
|
|
60
|
+
<Switch {...controlProps} label="Off" />
|
|
61
|
+
</StoryCell>
|
|
62
|
+
|
|
63
|
+
<StoryCell label="on">
|
|
64
|
+
<Switch {...controlProps} isChecked label="On" />
|
|
65
|
+
</StoryCell>
|
|
66
|
+
|
|
67
|
+
<StoryCell label="disabled, off">
|
|
68
|
+
<Switch
|
|
69
|
+
{...controlProps}
|
|
70
|
+
isDisabled
|
|
71
|
+
label="Disabled"
|
|
72
|
+
/>
|
|
73
|
+
</StoryCell>
|
|
74
|
+
|
|
75
|
+
<StoryCell label="disabled, on">
|
|
76
|
+
<Switch
|
|
77
|
+
{...controlProps}
|
|
78
|
+
isChecked
|
|
79
|
+
isDisabled
|
|
80
|
+
label="Disabled and on"
|
|
81
|
+
/>
|
|
82
|
+
</StoryCell>
|
|
83
|
+
</StoryGrid>
|
|
84
|
+
</StorySection>
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A `button role="switch"`, so its keyboard path is the button's:
|
|
90
|
+
* Tab reaches it, Space and Enter flip it.
|
|
91
|
+
*/
|
|
92
|
+
export const Interactive: Story = {
|
|
93
|
+
args: { label: "Automatic imports" },
|
|
94
|
+
}
|