@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.
Files changed (56) hide show
  1. package/dist/Checkbox/Checkbox.d.ts +68 -0
  2. package/dist/Checkbox/Checkbox.d.ts.map +1 -0
  3. package/dist/Checkbox/Checkbox.js +108 -0
  4. package/dist/Checkbox/Checkbox.js.map +1 -0
  5. package/dist/Field/Field.d.ts.map +1 -1
  6. package/dist/Field/Field.js +12 -1
  7. package/dist/Field/Field.js.map +1 -1
  8. package/dist/Menu/Menu.d.ts +60 -2
  9. package/dist/Menu/Menu.d.ts.map +1 -1
  10. package/dist/Menu/Menu.js +58 -11
  11. package/dist/Menu/Menu.js.map +1 -1
  12. package/dist/RadioGroup/RadioGroup.d.ts +54 -0
  13. package/dist/RadioGroup/RadioGroup.d.ts.map +1 -0
  14. package/dist/RadioGroup/RadioGroup.js +121 -0
  15. package/dist/RadioGroup/RadioGroup.js.map +1 -0
  16. package/dist/RadioGroup/RadioGroupOption.d.ts +27 -0
  17. package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -0
  18. package/dist/RadioGroup/RadioGroupOption.js +75 -0
  19. package/dist/RadioGroup/RadioGroupOption.js.map +1 -0
  20. package/dist/Switch/Switch.d.ts +60 -0
  21. package/dist/Switch/Switch.d.ts.map +1 -0
  22. package/dist/Switch/Switch.js +106 -0
  23. package/dist/Switch/Switch.js.map +1 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/slotWiring.d.ts +9 -7
  29. package/dist/slotWiring.d.ts.map +1 -1
  30. package/dist/slotWiring.js +10 -70
  31. package/dist/slotWiring.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/Checkbox/Checkbox.mdx +71 -0
  34. package/src/Checkbox/Checkbox.stories.tsx +122 -0
  35. package/src/Checkbox/Checkbox.test.tsx +109 -0
  36. package/src/Checkbox/Checkbox.tsx +205 -0
  37. package/src/Combobox/Combobox.stories.tsx +19 -4
  38. package/src/Field/Field.stories.tsx +19 -0
  39. package/src/Field/Field.test.tsx +30 -2
  40. package/src/Field/Field.tsx +13 -1
  41. package/src/Menu/Menu.stories.tsx +80 -2
  42. package/src/Menu/Menu.test.tsx +102 -0
  43. package/src/Menu/Menu.tsx +156 -17
  44. package/src/RadioGroup/RadioGroup.mdx +67 -0
  45. package/src/RadioGroup/RadioGroup.stories.tsx +140 -0
  46. package/src/RadioGroup/RadioGroup.test.tsx +232 -0
  47. package/src/RadioGroup/RadioGroup.tsx +223 -0
  48. package/src/RadioGroup/RadioGroupOption.tsx +148 -0
  49. package/src/Switch/Switch.mdx +55 -0
  50. package/src/Switch/Switch.stories.tsx +116 -0
  51. package/src/Switch/Switch.test.tsx +115 -0
  52. package/src/Switch/Switch.tsx +183 -0
  53. package/src/index.ts +9 -0
  54. package/src/slotWiring.ts +15 -107
  55. package/src/sourceRules.test.ts +11 -1
  56. package/src/tailwindCandidates.test.ts +9 -7
@@ -0,0 +1,232 @@
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
+ })
192
+
193
+ test("a read-only group is announced and its choice cannot move", async () => {
194
+ const { canvas, canvasElement } =
195
+ await mountStory(AllStates)
196
+
197
+ const group = expectAgentDrivable(canvas, {
198
+ name: "Naming scheme, read-only",
199
+ role: "radiogroup",
200
+ })
201
+
202
+ await expect(group).toHaveAttribute(
203
+ "aria-readonly",
204
+ "true",
205
+ )
206
+
207
+ // The checked option shows at full contrast; that is the value the
208
+ // read-only group is displaying.
209
+ await waitFor(() => {
210
+ expect(
211
+ group.querySelector('[aria-checked="true"]'),
212
+ ).toHaveTextContent("Match AniDB titles")
213
+ })
214
+
215
+ const other = Array.from(
216
+ group.querySelectorAll<HTMLButtonElement>(
217
+ '[role="radio"]',
218
+ ),
219
+ ).find(
220
+ (one) => one.textContent === "Use a custom pattern",
221
+ )
222
+
223
+ await userEvent.click(other ?? group)
224
+
225
+ // Selection-follows-focus is severed when read-only — a click on
226
+ // another option does not move the choice.
227
+ await expect(
228
+ group.querySelector('[aria-checked="true"]'),
229
+ ).toHaveTextContent("Match AniDB titles")
230
+
231
+ await expectNoAxeViolations(canvasElement)
232
+ })
@@ -0,0 +1,223 @@
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
+ /**
29
+ * Shows which option is chosen at full contrast but refuses to move
30
+ * the choice — the arrow keys and clicks no longer select, and the
31
+ * group announces `aria-readonly`. Focus can still travel the
32
+ * options so a keyboard user can read them.
33
+ */
34
+ isReadOnly?: boolean
35
+ /** The group's accessible name. Required. */
36
+ label: string
37
+ onChange?: (selectedValue: string | null) => void
38
+ /** **Initial** only. Charcuterie owns it from then on. */
39
+ selectedValue?: string
40
+ size?: ControlSize
41
+ }
42
+
43
+ const GAP_CLASS: Record<ControlSize, string> = {
44
+ sm: "gap-1",
45
+ md: "gap-1.5",
46
+ lg: "gap-2",
47
+ }
48
+
49
+ /**
50
+ * One choice out of many, each on its own row with a label the size
51
+ * of prose — the stacked sibling of `SegmentedControl`.
52
+ *
53
+ * The two are the *same* composition — `SinglePicker` + `RovingFocus`,
54
+ * a `radiogroup` of `radio`s where selection follows focus — and a
55
+ * different shape. `SegmentedControl` is a compact strip for a
56
+ * handful of short options that share a control row; `RadioGroup` is
57
+ * the form-length list where each option carries a real sentence and
58
+ * an in-line strip would wrap. Picking between them is layout, not
59
+ * behaviour, which is why the keyboard model here is identical and
60
+ * lives in the same two hooks.
61
+ *
62
+ * ### `radiogroup`, not a stack of checkboxes
63
+ *
64
+ * A column of `<input type="checkbox">` is the **independent
65
+ * toggles** pattern: nothing says the options are mutually
66
+ * exclusive, and a screen reader never announces "1 of 4". A single
67
+ * choice from a named set is a radio group, and its arrow keys both
68
+ * move focus and check — APG's one activation mode, the same reason
69
+ * `SegmentedControl` has no `manual` prop.
70
+ */
71
+ export const RadioGroup = ({
72
+ className,
73
+ items,
74
+ isReadOnly = false,
75
+ label,
76
+ onChange,
77
+ selectedValue,
78
+ size = "md",
79
+ }: RadioGroupProps): ReactNode => {
80
+ const groupRef = useRef<HTMLDivElement>(null)
81
+
82
+ const optionElements = useRef(
83
+ new Map<string, HTMLButtonElement>(),
84
+ )
85
+
86
+ const initialValue =
87
+ selectedValue ??
88
+ items.find((one) => !one.isDisabled)?.value ??
89
+ null
90
+
91
+ const selection = useSinglePicker<string>({
92
+ onChange,
93
+ selectedValue: initialValue,
94
+ })
95
+
96
+ const focus = useRovingFocus<string>({
97
+ activeValue: initialValue,
98
+ })
99
+
100
+ const { select } = selection
101
+
102
+ const { activeValue, setActiveValue } = focus
103
+
104
+ /**
105
+ * Selected **or** pending. Members register from an effect, so on
106
+ * the first paint `selectedValue` is still null and the intent
107
+ * lives in `pendingValue` — reading only the former renders a
108
+ * group with nothing checked, then corrects itself a frame later.
109
+ */
110
+ const checkedValue =
111
+ selection.selectedValue ?? selection.pendingValue
112
+
113
+ const chooseOption = useCallback(
114
+ (value: string) => {
115
+ // Focus may still land on the option so it can be read; only the
116
+ // choice is frozen when read-only.
117
+ setActiveValue(value)
118
+
119
+ if (!isReadOnly) {
120
+ select(value)
121
+ }
122
+ },
123
+ [isReadOnly, select, setActiveValue],
124
+ )
125
+
126
+ useEffect(() => {
127
+ // Selection follows focus — the radio-group rule, and the one
128
+ // line a `manual` mode this component deliberately lacks would
129
+ // remove. Read-only severs exactly this link: focus still travels
130
+ // for reading, but moving it no longer moves the choice.
131
+ if (activeValue !== null && !isReadOnly) {
132
+ select(activeValue)
133
+ }
134
+ }, [activeValue, isReadOnly, select])
135
+
136
+ useEffect(() => {
137
+ const group = groupRef.current
138
+
139
+ if (
140
+ !group ||
141
+ activeValue === null ||
142
+ !group.contains(document.activeElement)
143
+ ) {
144
+ return
145
+ }
146
+
147
+ optionElements.current.get(activeValue)?.focus()
148
+ }, [activeValue])
149
+
150
+ return (
151
+ <div
152
+ aria-label={label}
153
+ aria-readonly={isReadOnly || undefined}
154
+ className={toClassName(
155
+ "flex flex-col",
156
+ GAP_CLASS[size],
157
+ className,
158
+ )}
159
+ onKeyDown={(keyEvent) => {
160
+ const commands: Record<string, () => void> = {
161
+ ArrowDown: focus.next,
162
+ ArrowLeft: focus.previous,
163
+ ArrowRight: focus.next,
164
+ ArrowUp: focus.previous,
165
+ End: focus.last,
166
+ Home: focus.first,
167
+ }
168
+
169
+ const command = commands[keyEvent.key]
170
+
171
+ if (command) {
172
+ // Arrow keys scroll the page by default, and a control
173
+ // that scrolls the window while it moves focus is the
174
+ // hand-rolled version of this component.
175
+ keyEvent.preventDefault()
176
+
177
+ command()
178
+
179
+ return
180
+ }
181
+
182
+ // Space checks the focused option. Selection already follows
183
+ // focus, so this is almost always a no-op — one idempotent
184
+ // command, not a second code path — and read-only skips it.
185
+ if (
186
+ keyEvent.key === " " &&
187
+ activeValue !== null &&
188
+ !isReadOnly
189
+ ) {
190
+ keyEvent.preventDefault()
191
+
192
+ select(activeValue)
193
+ }
194
+ }}
195
+ ref={groupRef}
196
+ role="radiogroup"
197
+ >
198
+ {items.map((item) => (
199
+ <RadioGroupOption
200
+ isChecked={checkedValue === item.value}
201
+ isReadOnly={isReadOnly}
202
+ item={item}
203
+ key={item.value}
204
+ onChoose={chooseOption}
205
+ registerFocus={focus.register}
206
+ registerSelection={selection.register}
207
+ size={size}
208
+ // The roving-tabindex rule read from the core rather than
209
+ // restated here: exactly one option is in the tab order, so
210
+ // Tab enters and leaves the group once.
211
+ tabIndex={selectTabIndex(focus, item.value)}
212
+ trackElement={(value, element) => {
213
+ if (element) {
214
+ optionElements.current.set(value, element)
215
+ } else {
216
+ optionElements.current.delete(value)
217
+ }
218
+ }}
219
+ />
220
+ ))}
221
+ </div>
222
+ )
223
+ }
@@ -0,0 +1,148 @@
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
+ isReadOnly: boolean
12
+ item: RadioItem
13
+ onChoose: (value: string) => void
14
+ /** `RovingFocus.register` — membership of the arrow-key group. */
15
+ registerFocus: (value: string) => () => void
16
+ /** `SinglePicker.register` — membership of the choice. */
17
+ registerSelection: (value: string) => () => void
18
+ size: ControlSize
19
+ tabIndex: number
20
+ trackElement: (
21
+ value: string,
22
+ element: HTMLButtonElement | null,
23
+ ) => void
24
+ }
25
+
26
+ // Deliberately the `Switch`'s `THUMB_SIZE_CLASS`, value for value — a
27
+ // radio's ring and a switch's knob are then the same circle at every
28
+ // density, which is what makes the family read as one set.
29
+ const RING_SIZE_CLASS: Record<ControlSize, string> = {
30
+ sm: "size-3",
31
+ md: "size-4",
32
+ lg: "size-5",
33
+ }
34
+
35
+ const DOT_SIZE_CLASS: Record<ControlSize, string> = {
36
+ sm: "size-1.5",
37
+ md: "size-2",
38
+ lg: "size-2.5",
39
+ }
40
+
41
+ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
42
+ sm: "text-sm",
43
+ md: "text-md",
44
+ lg: "text-lg",
45
+ }
46
+
47
+ /**
48
+ * Its own file for the same reason `SegmentedOption` is: **both
49
+ * registrations are effects**, and an effect cannot run in a loop.
50
+ *
51
+ * The ring and the dot are two elements rather than a bordered box
52
+ * with a background, so the checked dot can be centred by the grid
53
+ * and sized independently of the ring — a filled `bg-intent-accent`
54
+ * ring with no inner dot reads as a checkbox, not a radio.
55
+ */
56
+ export const RadioGroupOption = ({
57
+ isChecked,
58
+ isReadOnly,
59
+ item,
60
+ onChoose,
61
+ registerFocus,
62
+ registerSelection,
63
+ size,
64
+ tabIndex,
65
+ trackElement,
66
+ }: RadioGroupOptionProps): ReactNode => {
67
+ const { isDisabled = false, label, value } = item
68
+
69
+ useEffect(
70
+ () => registerSelection(value),
71
+ [registerSelection, value],
72
+ )
73
+
74
+ useEffect(() => {
75
+ if (isDisabled) {
76
+ return
77
+ }
78
+
79
+ return registerFocus(value)
80
+ }, [isDisabled, registerFocus, value])
81
+
82
+ return (
83
+ // 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.
84
+ <button
85
+ aria-checked={isChecked}
86
+ className={toClassName(
87
+ "inline-flex items-center gap-2 rounded-sm text-content-secondary text-start transition-colors duration-(--duration-fast) ease-standard",
88
+ TEXT_SIZE_CLASS[size],
89
+ // Disabled turns the whole option down with `opacity-60`, the
90
+ // same family treatment as `Checkbox` and `Switch`, rather than
91
+ // fading the ring to a token that disappeared on a pale theme.
92
+ // Read-only keeps full contrast — only the cursor and the hover
93
+ // affordance say it will not move.
94
+ isDisabled
95
+ ? "cursor-not-allowed opacity-60"
96
+ : isReadOnly
97
+ ? "cursor-default"
98
+ : "cursor-pointer hover:text-content-primary",
99
+ FOCUS_RING_CLASS,
100
+ )}
101
+ disabled={isDisabled}
102
+ onClick={() => {
103
+ onChoose(value)
104
+ }}
105
+ ref={(element) => {
106
+ trackElement(value, element)
107
+ }}
108
+ role="radio"
109
+ tabIndex={tabIndex}
110
+ type="button"
111
+ >
112
+ <span
113
+ className={toClassName(
114
+ // `border-2` matches the `Switch` thumb's ring weight and the
115
+ // `Checkbox` box, and `RING_SIZE_CLASS` matches the thumb's
116
+ // diameter — so a radio dot and a switch knob are the same
117
+ // circle. A disabled ring holds `border-default` rather than
118
+ // fading to the `border-subtle` that vanished on a pale theme.
119
+ "grid shrink-0 place-items-center rounded-full border-2 transition-colors duration-(--duration-fast) ease-standard",
120
+ // Read-only wears the neutral intent and a softer resting edge
121
+ // — the same "readable but not an actionable accent" language
122
+ // as `Checkbox` and `Switch`.
123
+ isReadOnly
124
+ ? isChecked
125
+ ? "border-intent-neutral-solid"
126
+ : "border-border-default"
127
+ : isChecked
128
+ ? "border-intent-accent-solid"
129
+ : "border-border-strong",
130
+ RING_SIZE_CLASS[size],
131
+ )}
132
+ >
133
+ <span
134
+ className={toClassName(
135
+ "rounded-full transition-transform duration-(--duration-fast) ease-standard",
136
+ isReadOnly
137
+ ? "bg-intent-neutral-solid"
138
+ : "bg-intent-accent-solid",
139
+ isChecked ? "scale-100" : "scale-0",
140
+ DOT_SIZE_CLASS[size],
141
+ )}
142
+ />
143
+ </span>
144
+
145
+ <span>{label}</span>
146
+ </button>
147
+ )
148
+ }
@@ -0,0 +1,55 @@
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
+ ## Disabled dims, read-only does not
47
+
48
+ `isDisabled` removes the switch from the tab order and the pointer and turns the whole
49
+ control down with `opacity-60` — a setting the user cannot currently change, a policy
50
+ forced from above, still fully visible. `isReadOnly` is the other half: full contrast so
51
+ the state stays legible, but the accent gives way to the **neutral** intent and the flip
52
+ is refused (`aria-readonly`, the `onClick` guarded). A read-only switch is a value you may
53
+ read here and change elsewhere; a disabled one is a control that is simply off-limits now.
54
+
55
+ <Canvas of={SwitchStories.Interactive} />