@arbor-education/design-system.components 0.12.0 → 0.13.1

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 (177) hide show
  1. package/.agent-memory/blanche-designspert/MEMORY.md +189 -0
  2. package/.agent-memory/dorothy-fact-checker/MEMORY.md +228 -0
  3. package/.agent-memory/dorothy-fact-checker/numberinput_component.md +53 -0
  4. package/.agent-memory/dorothy-fact-checker/progress_component.md +36 -0
  5. package/.agent-memory/rose-storybookspert/MEMORY.md +105 -0
  6. package/.agent-memory/sophia-componentspert/MEMORY.md +34 -0
  7. package/{.claude/agent-memory → .agent-memory}/sophia-componentspert/components.md +170 -17
  8. package/{.claude → .gather}/agents/blanche-designspert.md +7 -2
  9. package/{.claude → .gather}/agents/dorothy-fact-checker.md +7 -2
  10. package/{.claude → .gather}/agents/rose-storybookspert.md +80 -11
  11. package/{.claude → .gather}/agents/sophia-componentspert.md +9 -4
  12. package/.gather/gather.yaml +9 -0
  13. package/{CLAUDE.md → .gather/instructions/project-overview.md} +42 -9
  14. package/{.claude → .gather}/skills/analyze-design/README.md +5 -0
  15. package/{.claude → .gather}/skills/analyze-design/SKILL.md +1 -1
  16. package/.gather/skills/analyze-design/meta.md +4 -0
  17. package/{.claude → .gather}/skills/create-page/README.md +5 -0
  18. package/{.claude → .gather}/skills/create-page/design-analysis-template.md +5 -0
  19. package/.gather/skills/create-page/meta.md +4 -0
  20. package/{.claude → .gather}/skills/create-page/page-template.scss +5 -0
  21. package/{.claude → .gather}/skills/create-page/page-template.tsx +5 -0
  22. package/{.claude → .gather}/skills/map-legacy/README.md +5 -0
  23. package/.gather/skills/map-legacy/meta.md +4 -0
  24. package/{.claude → .gather}/skills/migrate-page/README.md +5 -0
  25. package/.gather/skills/migrate-page/meta.md +4 -0
  26. package/.gather/skills/write-stories/README.md +157 -0
  27. package/.gather/skills/write-stories/SKILL.md +841 -0
  28. package/.gather/skills/write-stories/meta.md +4 -0
  29. package/.ralph/storybook-upgrade/knowledge.md +308 -0
  30. package/.ralph/storybook-upgrade/prd.json +777 -0
  31. package/.ralph/storybook-upgrade/progress.md +342 -0
  32. package/.storybook/DocsTemplate.tsx +122 -0
  33. package/.storybook/preview.ts +40 -0
  34. package/.stylelintignore +2 -0
  35. package/CHANGELOG.md +12 -0
  36. package/{.claude/component-library.md → component-library.md} +27 -10
  37. package/dist/components/badge/Badge.stories.d.ts +85 -6
  38. package/dist/components/badge/Badge.stories.d.ts.map +1 -1
  39. package/dist/components/badge/Badge.stories.js +626 -27
  40. package/dist/components/badge/Badge.stories.js.map +1 -1
  41. package/dist/components/banner/Banner.stories.d.ts +129 -63
  42. package/dist/components/banner/Banner.stories.d.ts.map +1 -1
  43. package/dist/components/banner/Banner.stories.js +855 -39
  44. package/dist/components/banner/Banner.stories.js.map +1 -1
  45. package/dist/components/button/Button.stories.d.ts +148 -8
  46. package/dist/components/button/Button.stories.d.ts.map +1 -1
  47. package/dist/components/button/Button.stories.js +1089 -80
  48. package/dist/components/button/Button.stories.js.map +1 -1
  49. package/dist/components/datePicker/DatePicker.d.ts +1 -0
  50. package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
  51. package/dist/components/datePicker/DatePicker.js +2 -2
  52. package/dist/components/datePicker/DatePicker.js.map +1 -1
  53. package/dist/components/datePicker/DatePicker.stories.d.ts +1 -0
  54. package/dist/components/datePicker/DatePicker.stories.d.ts.map +1 -1
  55. package/dist/components/dot/Dot.stories.d.ts +46 -11
  56. package/dist/components/dot/Dot.stories.d.ts.map +1 -1
  57. package/dist/components/dot/Dot.stories.js +504 -15
  58. package/dist/components/dot/Dot.stories.js.map +1 -1
  59. package/dist/components/dropdown/Dropdown.stories.d.ts +89 -14
  60. package/dist/components/dropdown/Dropdown.stories.d.ts.map +1 -1
  61. package/dist/components/dropdown/Dropdown.stories.js +769 -17
  62. package/dist/components/dropdown/Dropdown.stories.js.map +1 -1
  63. package/dist/components/formField/FormField.stories.d.ts +95 -35
  64. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  65. package/dist/components/formField/FormField.stories.js +1174 -69
  66. package/dist/components/formField/FormField.stories.js.map +1 -1
  67. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts +96 -9
  68. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts.map +1 -1
  69. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js +717 -10
  70. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js.map +1 -1
  71. package/dist/components/formField/inputs/number/NumberInput.stories.d.ts +149 -11
  72. package/dist/components/formField/inputs/number/NumberInput.stories.d.ts.map +1 -1
  73. package/dist/components/formField/inputs/number/NumberInput.stories.js +624 -10
  74. package/dist/components/formField/inputs/number/NumberInput.stories.js.map +1 -1
  75. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.d.ts +74 -1
  76. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.d.ts.map +1 -1
  77. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.js +673 -44
  78. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.js.map +1 -1
  79. package/dist/components/formField/inputs/text/TextInput.stories.d.ts +119 -1
  80. package/dist/components/formField/inputs/text/TextInput.stories.d.ts.map +1 -1
  81. package/dist/components/formField/inputs/text/TextInput.stories.js +549 -10
  82. package/dist/components/formField/inputs/text/TextInput.stories.js.map +1 -1
  83. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +129 -4
  84. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
  85. package/dist/components/formField/inputs/textArea/TextArea.stories.js +577 -3
  86. package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
  87. package/dist/components/heading/Heading.stories.d.ts +449 -50
  88. package/dist/components/heading/Heading.stories.d.ts.map +1 -1
  89. package/dist/components/heading/Heading.stories.js +536 -60
  90. package/dist/components/heading/Heading.stories.js.map +1 -1
  91. package/dist/components/icon/Icon.stories.d.ts +81 -10
  92. package/dist/components/icon/Icon.stories.d.ts.map +1 -1
  93. package/dist/components/icon/Icon.stories.js +979 -8
  94. package/dist/components/icon/Icon.stories.js.map +1 -1
  95. package/dist/components/pill/Pill.stories.d.ts +71 -19
  96. package/dist/components/pill/Pill.stories.d.ts.map +1 -1
  97. package/dist/components/pill/Pill.stories.js +573 -14
  98. package/dist/components/pill/Pill.stories.js.map +1 -1
  99. package/dist/components/progress/Progress.stories.d.ts +75 -298
  100. package/dist/components/progress/Progress.stories.d.ts.map +1 -1
  101. package/dist/components/progress/Progress.stories.js +449 -52
  102. package/dist/components/progress/Progress.stories.js.map +1 -1
  103. package/dist/components/separator/Separator.stories.d.ts +58 -5
  104. package/dist/components/separator/Separator.stories.d.ts.map +1 -1
  105. package/dist/components/separator/Separator.stories.js +443 -4
  106. package/dist/components/separator/Separator.stories.js.map +1 -1
  107. package/dist/components/table/Table.d.ts +7 -0
  108. package/dist/components/table/Table.d.ts.map +1 -1
  109. package/dist/components/table/Table.js +9 -0
  110. package/dist/components/table/Table.js.map +1 -1
  111. package/dist/components/table/Table.stories.d.ts +1 -0
  112. package/dist/components/table/Table.stories.d.ts.map +1 -1
  113. package/dist/components/table/Table.stories.js +87 -0
  114. package/dist/components/table/Table.stories.js.map +1 -1
  115. package/dist/components/table/Table.test.js +49 -1
  116. package/dist/components/table/Table.test.js.map +1 -1
  117. package/dist/components/table/cellEditors/DateCellEditor.d.ts +3 -0
  118. package/dist/components/table/cellEditors/DateCellEditor.d.ts.map +1 -0
  119. package/dist/components/table/cellEditors/DateCellEditor.js +13 -0
  120. package/dist/components/table/cellEditors/DateCellEditor.js.map +1 -0
  121. package/dist/components/table/cellEditors/DateCellEditor.test.d.ts +2 -0
  122. package/dist/components/table/cellEditors/DateCellEditor.test.d.ts.map +1 -0
  123. package/dist/components/table/cellEditors/DateCellEditor.test.js +81 -0
  124. package/dist/components/table/cellEditors/DateCellEditor.test.js.map +1 -0
  125. package/dist/components/tag/Tag.stories.d.ts +116 -5
  126. package/dist/components/tag/Tag.stories.d.ts.map +1 -1
  127. package/dist/components/tag/Tag.stories.js +581 -28
  128. package/dist/components/tag/Tag.stories.js.map +1 -1
  129. package/dist/index.css +8 -1
  130. package/dist/index.css.map +1 -1
  131. package/dist/index.d.ts +0 -5
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +0 -5
  134. package/dist/index.js.map +1 -1
  135. package/eslint.config.mts +5 -1
  136. package/package.json +3 -3
  137. package/src/components/badge/Badge.stories.tsx +869 -42
  138. package/src/components/banner/Banner.stories.tsx +1081 -63
  139. package/src/components/button/Button.stories.tsx +1394 -99
  140. package/src/components/datePicker/DatePicker.tsx +3 -0
  141. package/src/components/dot/Dot.stories.tsx +723 -32
  142. package/src/components/dropdown/Dropdown.stories.tsx +1174 -35
  143. package/src/components/formField/FormField.stories.tsx +1522 -105
  144. package/src/components/formField/inputs/checkbox/CheckboxInput.stories.tsx +1020 -15
  145. package/src/components/formField/inputs/number/NumberInput.stories.tsx +908 -15
  146. package/src/components/formField/inputs/radio/RadioButtonInput.stories.tsx +932 -51
  147. package/src/components/formField/inputs/text/TextInput.stories.tsx +773 -13
  148. package/src/components/formField/inputs/textArea/TextArea.stories.tsx +756 -8
  149. package/src/components/heading/Heading.stories.tsx +752 -120
  150. package/src/components/icon/Icon.stories.tsx +1446 -12
  151. package/src/components/pill/Pill.stories.tsx +867 -21
  152. package/src/components/progress/Progress.stories.tsx +625 -58
  153. package/src/components/separator/Separator.stories.tsx +730 -8
  154. package/src/components/separator/separator.scss +12 -3
  155. package/src/components/table/Table.stories.tsx +102 -0
  156. package/src/components/table/Table.test.tsx +82 -3
  157. package/src/components/table/Table.tsx +9 -0
  158. package/src/components/table/cellEditors/DateCellEditor.test.tsx +109 -0
  159. package/src/components/table/cellEditors/DateCellEditor.tsx +27 -0
  160. package/src/components/tag/Tag.stories.tsx +755 -53
  161. package/src/index.ts +0 -5
  162. package/.claude/agent-memory/blanche-designspert/MEMORY.md +0 -64
  163. package/.claude/agent-memory/dorothy-fact-checker/MEMORY.md +0 -129
  164. package/.claude/agent-memory/rose-storybookspert/MEMORY.md +0 -29
  165. package/.claude/agent-memory/sophia-componentspert/MEMORY.md +0 -14
  166. package/.claude/design-assessment-daily-attendance-2026-04-10.md +0 -566
  167. package/.claude/figma-assessment-7154-58899.md +0 -404
  168. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-11086-97537.md +0 -392
  169. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-551-41974.md +0 -474
  170. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-551-43094.md +0 -462
  171. package/.claude/figma-assessment-fcFK4CGzkz2fVyY3koX8ZE-7154-59061.md +0 -440
  172. package/.claude/migration-report-custom-report-writer-2026-02-19.md +0 -591
  173. /package/{.claude/agent-memory → .agent-memory}/blanche-designspert/token-review-patterns.md +0 -0
  174. /package/{.claude/agent-memory → .agent-memory}/rose-storybookspert/patterns.md +0 -0
  175. /package/{.claude → .gather}/skills/create-page/SKILL.md +0 -0
  176. /package/{.claude → .gather}/skills/map-legacy/SKILL.md +0 -0
  177. /package/{.claude → .gather}/skills/migrate-page/SKILL.md +0 -0
@@ -1,15 +1,226 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import {
3
+ Controls,
4
+ Heading as DocHeading,
5
+ Markdown,
6
+ Primary as DocPrimary,
7
+ Stories,
8
+ Subtitle,
9
+ Title,
10
+ } from '@storybook/addon-docs/blocks';
11
+ import { useState } from 'react';
2
12
  import { fn } from 'storybook/test';
3
-
4
13
  import { RadioButtonInput } from './RadioButtonInput';
5
14
  import { RadioButtonGroup } from './RadioButtonGroup';
6
- import { useState } from 'react';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Component description — built as joined arrays to avoid no-useless-escape
18
+ // on backtick code spans inside template literals.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const DESCRIPTION_INTRO = [
22
+ 'The **RadioButtonInput** is the Arbor design system radio control — a labelled, accessible single-select',
23
+ 'input for choosing exactly one option from a mutually exclusive set.',
24
+ 'Always use at least two `RadioButtonInput` elements grouped by a shared `name` attribute,',
25
+ 'or compose them with `RadioButtonGroup` which handles the group semantics automatically.',
26
+ ].join(' ');
27
+
28
+ const USAGE_GUIDANCE = [
29
+ '### When to use',
30
+ '',
31
+ '- **Single-select from a fixed set** — when exactly one option must be chosen and no "none" state',
32
+ ' is meaningful (e.g. "Which grouping should the attendance report use?")',
33
+ '- **Mutually exclusive choices** — when selecting one option implicitly deselects all others',
34
+ ' (e.g. "Notification method: Email / SMS / Post")',
35
+ '- **Short lists of 2–6 options** — radio buttons work best when all options are visible at once.',
36
+ ' For longer lists, consider `SelectDropdown` instead.',
37
+ '- **A default must always exist** — unlike checkboxes, radio buttons communicate that one option',
38
+ ' is always required. Pre-select the most common or safest option.',
39
+ '',
40
+ '---',
41
+ '',
42
+ '### When NOT to use',
43
+ '',
44
+ '| Instead of RadioButtonInput... | Use... | Why |',
45
+ '|---|---|---|',
46
+ '| Multiple options can be selected | [`CheckboxInput`](?path=/docs/components-formfield-inputs-checkbox--docs) | Checkboxes communicate multi-select; radios communicate single-select |',
47
+ '| Long lists (7+ options) | [`SelectDropdown`](?path=/docs/components-formfield-inputs-selectdropdown--docs) | Dropdowns save vertical space and are easier to scan |',
48
+ '| A single on/off toggle (instant apply) | [`Toggle`](?path=/docs/components-toggle--docs) | Toggle communicates immediate effect; radio needs a Submit/Save action |',
49
+ '| A lone single radio button | (avoid) | A lone radio button is always confusing — radios only make sense in groups of 2+ |',
50
+ '',
51
+ '---',
52
+ '',
53
+ '### Design guidance',
54
+ '',
55
+ '- **Always use in groups of 2+** — a single radio button has no meaning. The browser only enforces',
56
+ ' mutual exclusivity when there are multiple radios sharing the same `name` attribute.',
57
+ '- **Click target is the entire row** — the `<label>` element wraps both the indicator circle and the',
58
+ ' label text, so clicking anywhere on the row selects that option.',
59
+ '- **Vertical layout is the default** — stack radio buttons vertically. Horizontal layouts are only',
60
+ ' appropriate for very short sets with brief labels (e.g. "Yes / No").',
61
+ '- **Pre-select a sensible default** — radio buttons imply that a choice is required. Always start with',
62
+ ' one option selected rather than leaving all options unselected.',
63
+ '- **Error validation is at the field level** — do not show per-option errors.',
64
+ ' Render one error message below the entire group (see the `WithError` and `RadioGroupWithError` stories).',
65
+ '- **Keyboard interaction** — Tab moves focus to the radio group; arrow keys move between options',
66
+ ' within the group; Space selects the focused option. This is native browser behaviour.',
67
+ ].join('\n');
68
+
69
+ const DEVELOPER_NOTES = [
70
+ '### Critical gotchas',
71
+ '',
72
+ '#### 1. `hasError` is ARIA-only — it makes zero visual difference',
73
+ '`hasError={true}` sets `aria-invalid="true"` on the native `<input>`. There are NO CSS rules',
74
+ 'that change the appearance of the indicator circle based on `aria-invalid`.',
75
+ 'To show a visible error state, YOU must render an error message element below the group.',
76
+ 'See the `WithError` and `RadioGroupWithError` stories for the correct pattern.',
77
+ '',
78
+ '```tsx',
79
+ '// WRONG — hasError alone shows no visible change',
80
+ '<RadioButtonInput hasError name="period" value="term" label="Current term" checked onChange={() => {}} />',
81
+ '',
82
+ '// CORRECT — pair hasError with a visible error message rendered by the consumer',
83
+ '<div>',
84
+ ' <RadioButtonInput hasError name="period" value="term" label="Current term" checked onChange={() => {}} />',
85
+ ' <p role="alert" style={{ color: "var(--form-field-text-error-color-error-text)" }}>',
86
+ ' Please select a valid report period.',
87
+ ' </p>',
88
+ '</div>',
89
+ '```',
90
+ '',
91
+ '#### 2. `name` is not TypeScript-required but is functionally mandatory',
92
+ 'TypeScript will not warn you if you omit `name`, but without it the browser cannot enforce',
93
+ 'mutual exclusivity — all radios in the "group" can be selected simultaneously.',
94
+ 'Always provide a `name` prop that is shared across all options in a group.',
95
+ '',
96
+ '```tsx',
97
+ '// BAD — no name; browser cannot enforce mutual exclusivity',
98
+ '<RadioButtonInput value="email" label="Email" checked onChange={() => {}} />',
99
+ '<RadioButtonInput value="sms" label="SMS" checked={false} onChange={() => {}} />',
100
+ '',
101
+ '// GOOD — shared name groups them correctly',
102
+ '<RadioButtonInput name="notification-method" value="email" label="Email" checked onChange={() => {}} />',
103
+ '<RadioButtonInput name="notification-method" value="sms" label="SMS" checked={false} onChange={() => {}} />',
104
+ '```',
105
+ '',
106
+ '#### 3. `checked` defaults to `false` — this component is always controlled',
107
+ 'The component destructures `checked = false`, so React treats it as a controlled input from',
108
+ 'the moment it mounts. You must always pair `checked` with an `onChange` handler.',
109
+ 'Without `onChange`, the radio will appear frozen — clicking will not select it.',
110
+ '',
111
+ '#### 4. `option.id` is the React key in `RadioButtonGroup` — omitting it causes key warnings',
112
+ '`RadioButtonGroup` uses `option.id` as the React `key` when mapping over options.',
113
+ 'Every option object must include an `id` field, or React will warn about missing keys.',
114
+ '',
115
+ '#### 5. `RadioButtonGroup` API differs from `CheckboxGroup`',
116
+ '`RadioButtonGroup` uses a single `checkedValue: string` + `onChange` at the group level.',
117
+ '`CheckboxGroup` uses per-item `checked` + `onChange` in each option object.',
118
+ 'Do not mix up the two APIs.',
119
+ '',
120
+ '```tsx',
121
+ '// RadioButtonGroup — single checkedValue at group level',
122
+ '<RadioButtonGroup',
123
+ ' name="grouping"',
124
+ ' checkedValue={selectedValue}',
125
+ ' onChange={(e) => setSelectedValue(e.target.value)}',
126
+ ' options={[',
127
+ ' { id: "opt-year", value: "year-group", label: "Year group" },',
128
+ ' { id: "opt-reg", value: "reg-form", label: "Registration form" },',
129
+ ' ]}',
130
+ '/>',
131
+ '',
132
+ '// CheckboxGroup — per-item checked + onChange',
133
+ '<CheckboxGroup',
134
+ ' options={[',
135
+ ' { id: "opt-a", label: "Option A", checked: true, onChange: (e) => {} },',
136
+ ' { id: "opt-b", label: "Option B", checked: false, onChange: (e) => {} },',
137
+ ' ]}',
138
+ '/>',
139
+ '```',
140
+ '',
141
+ '---',
142
+ '',
143
+ '### Accessibility',
144
+ '',
145
+ '- **Keyboard interaction** — Tab moves focus to the radio group; arrow keys (Up/Down or Left/Right)',
146
+ ' cycle through options within the group; Space selects the focused option. This is native browser',
147
+ ' behaviour requiring no custom JavaScript.',
148
+ '- **`aria-invalid`** — set by `hasError={true}` on the real `<input>`. Screen readers will announce',
149
+ ' the input as invalid, but consumers must also render a visible error message so sighted users see it.',
150
+ '- **Group labelling** — always wrap `RadioButtonInput` elements in `RadioButtonGroup` (which uses',
151
+ ' `Fieldset` + `<legend>` internally) or in your own `<fieldset>` + `<legend>`. The legend is',
152
+ ' announced by screen readers as the group name before each individual option label.',
153
+ '- **Never use a lone radio button** — a single radio button is inaccessible because there is no way',
154
+ ' to deselect it. Use a checkbox instead, or add at least one more option.',
155
+ '- **`id` on each option** — required both as the React key in `RadioButtonGroup` and for potential',
156
+ ' `aria-describedby` associations (e.g. linking to an inline error message).',
157
+ '',
158
+ '---',
159
+ '',
160
+ '### TypeScript types',
161
+ '',
162
+ '```ts',
163
+ "import { RadioButtonInput, RadioButtonGroup } from '@arbor-education/design-system.components';",
164
+ '',
165
+ '// Prop type shorthand',
166
+ 'function MyField(props: RadioButtonInput.Props) { ... }',
167
+ 'function MyGroup(props: RadioButtonGroup.Props) { ... }',
168
+ '```',
169
+ '',
170
+ '| Type | Description |',
171
+ '|---|---|',
172
+ '| `RadioButtonInput.Props` | `label`, `hasError`, plus all `InputHTMLAttributes<HTMLInputElement>` except `type` |',
173
+ '| `RadioButtonGroup.Props` | `name`, `options: RadioButtonInputProps[]`, `checkedValue`, `onChange`, plus `Fieldset.Props` (`legend`, `HTMLFieldSetElement` attrs) |',
174
+ ].join('\n');
175
+
176
+ const RELATED_COMPONENTS = [
177
+ '## Related components',
178
+ '',
179
+ '[CheckboxInput](?path=/docs/components-formfield-inputs-checkbox--docs)',
180
+ '· [Toggle](?path=/docs/components-toggle--docs)',
181
+ '· [FormField](?path=/docs/components-formfield--docs)',
182
+ '· [Fieldset](?path=/docs/components-formfield-fieldset--docs)',
183
+ ].join('\n');
184
+
185
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Docs page
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function RadioButtonInputDocsPage() {
192
+ return (
193
+ <>
194
+ <Title />
195
+ <Subtitle />
196
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
197
+ <DocHeading>Interactive example</DocHeading>
198
+ <Markdown>{PROPS_INTRO}</Markdown>
199
+ <DocPrimary />
200
+ <Controls />
201
+ <DocHeading>Usage guidance</DocHeading>
202
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
203
+ <DocHeading>Developer notes</DocHeading>
204
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
205
+ <DocHeading>Examples</DocHeading>
206
+ <Stories title="" />
207
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
208
+ </>
209
+ );
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Meta
214
+ // ---------------------------------------------------------------------------
7
215
 
8
216
  const meta = {
9
217
  title: 'Components/FormField/Inputs/RadioButton',
10
218
  component: RadioButtonInput,
11
219
  parameters: {
12
220
  layout: 'centered',
221
+ docs: {
222
+ page: RadioButtonInputDocsPage,
223
+ },
13
224
  },
14
225
  tags: ['autodocs'],
15
226
  args: {
@@ -18,89 +229,759 @@ const meta = {
18
229
  argTypes: {
19
230
  checked: {
20
231
  control: 'boolean',
21
- description: 'Whether the radio button is checked',
232
+ description: [
233
+ 'Whether the radio button is selected. The component is always controlled — `checked` defaults to `false`',
234
+ 'in the component destructuring, so React treats it as a controlled input immediately.',
235
+ 'You must always pair this prop with an `onChange` handler, otherwise the radio will appear frozen.',
236
+ ].join(' '),
237
+ table: {
238
+ type: { summary: 'boolean' },
239
+ defaultValue: { summary: 'false' },
240
+ },
22
241
  },
23
242
  disabled: {
24
243
  control: 'boolean',
25
- description: 'Disable the radio button',
244
+ description: [
245
+ 'Disables the radio button. Applies muted styling and removes it from the tab order.',
246
+ 'The value is NOT submitted in a form when disabled.',
247
+ 'Always accompany a disabled radio with a tooltip or helper text explaining why it is disabled.',
248
+ ].join(' '),
249
+ table: {
250
+ type: { summary: 'boolean' },
251
+ defaultValue: { summary: 'false' },
252
+ },
26
253
  },
27
254
  hasError: {
28
255
  control: 'boolean',
29
- description: 'Show error state',
256
+ description: [
257
+ '**ARIA-only** — sets `aria-invalid="true"` on the native `<input>` element.',
258
+ 'This makes ZERO visual difference to the indicator circle — there are no CSS rules for the error state.',
259
+ 'Consumers must separately render a visible error message below the group.',
260
+ 'See the `WithError` and `RadioGroupWithError` stories for the correct pattern.',
261
+ ].join(' '),
262
+ table: {
263
+ type: { summary: 'boolean' },
264
+ },
30
265
  },
31
266
  label: {
32
267
  control: 'text',
33
- description: 'Label text for the radio button',
268
+ description: [
269
+ 'Visible label text rendered next to the indicator circle.',
270
+ 'When present, it is wrapped in a `.ds-radio-button-input__text` span and the click target',
271
+ 'expands to the entire row.',
272
+ 'When absent, only the indicator circle is rendered — always provide `aria-label` in that case.',
273
+ ].join(' '),
274
+ table: {
275
+ type: { summary: 'string' },
276
+ },
34
277
  },
35
278
  name: {
36
279
  control: 'text',
37
- description: 'Name attribute for the radio button group',
280
+ description: [
281
+ 'HTML `name` attribute — functionally mandatory even though TypeScript does not require it.',
282
+ 'All radio buttons in the same group must share the same `name` value so the browser can enforce',
283
+ 'mutual exclusivity (selecting one deselects all others with the same name).',
284
+ 'Omitting `name` means multiple radios can be "selected" simultaneously — a broken experience.',
285
+ ].join(' '),
286
+ table: {
287
+ type: { summary: 'string' },
288
+ },
38
289
  },
39
290
  value: {
40
291
  control: 'text',
41
- description: 'Value attribute for the radio button',
292
+ description: [
293
+ 'The value submitted with the form when this radio button is selected.',
294
+ 'Also the value used in `RadioButtonGroup` — the `checkedValue` prop is compared against each',
295
+ 'option\'s `value` to determine which radio is selected.',
296
+ ].join(' '),
297
+ table: {
298
+ type: { summary: 'string' },
299
+ },
300
+ },
301
+ onChange: {
302
+ description: [
303
+ 'Change event handler — required for controlled usage. Fires when the user selects this option.',
304
+ 'Access the selected value via `e.target.value`.',
305
+ 'Without this prop, a controlled radio will appear frozen when clicked.',
306
+ ].join(' '),
307
+ control: false,
308
+ table: {
309
+ type: { summary: 'ChangeEventHandler<HTMLInputElement>' },
310
+ },
311
+ },
312
+ id: {
313
+ control: 'text',
314
+ description: [
315
+ 'HTML `id` attribute — spread onto the native `<input>` element.',
316
+ 'Required by `RadioButtonGroup` (passed via `options`) to key list items.',
317
+ 'Also useful for associating external `aria-describedby` targets (e.g. an error message).',
318
+ ].join(' '),
319
+ table: {
320
+ type: { summary: 'string' },
321
+ },
322
+ },
323
+ className: {
324
+ control: 'text',
325
+ description: [
326
+ 'Additional CSS class names applied to the outer `<label>` container element',
327
+ '(the `.ds-radio-button-input__container` wrapper), not to the indicator circle itself.',
328
+ ].join(' '),
329
+ table: {
330
+ type: { summary: 'string' },
331
+ },
42
332
  },
43
333
  },
44
334
  } satisfies Meta<typeof RadioButtonInput>;
45
335
 
46
336
  export default meta;
47
- type Story = StoryObj<typeof meta>;
337
+ type Story = StoryObj<typeof RadioButtonInput>;
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Helper: attach a per-story description to docs
341
+ // ---------------------------------------------------------------------------
342
+
343
+ const withDescription = (story: Story, description: string | string[]): Story => ({
344
+ ...story,
345
+ parameters: {
346
+ ...story.parameters,
347
+ docs: {
348
+ ...story.parameters?.docs,
349
+ description: {
350
+ story: Array.isArray(description) ? description.join(' ') : description,
351
+ },
352
+ },
353
+ },
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Stateful template components
358
+ // Named components avoid hooks-in-callbacks lint issues (react-hooks plugin
359
+ // is NOT configured in this project — do not add eslint-disable comments).
360
+ // ---------------------------------------------------------------------------
361
+
362
+ const RadioGroupTemplate = () => {
363
+ const [grouping, setGrouping] = useState('year-group');
364
+
365
+ return (
366
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
367
+ <RadioButtonGroup
368
+ legend="Attendance grouping"
369
+ name="attendance-grouping"
370
+ checkedValue={grouping}
371
+ onChange={e => setGrouping(e.target.value)}
372
+ options={[
373
+ { id: 'opt-custom', value: 'custom-group', label: 'Custom group' },
374
+ { id: 'opt-year', value: 'year-group', label: 'Year group' },
375
+ { id: 'opt-reg', value: 'reg-form', label: 'Registration form' },
376
+ ]}
377
+ />
378
+ <p style={{ margin: 'var(--spacing-medium) 0 0', color: 'var(--color-grey-600)', fontSize: 'var(--font-size-2-13)' }}>
379
+ Selected:
380
+ {' '}
381
+ {grouping}
382
+ </p>
383
+ </div>
384
+ );
385
+ };
386
+
387
+ const RadioGroupWithErrorTemplate = () => {
388
+ const [method, setMethod] = useState('');
389
+
390
+ return (
391
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
392
+ <RadioButtonGroup
393
+ legend="Notification method"
394
+ name="notification-method"
395
+ checkedValue={method}
396
+ onChange={e => setMethod(e.target.value)}
397
+ options={[
398
+ { id: 'opt-email', value: 'email', label: 'Email', hasError: true },
399
+ { id: 'opt-sms', value: 'sms', label: 'SMS', hasError: true },
400
+ { id: 'opt-post', value: 'post', label: 'Post', hasError: true },
401
+ ]}
402
+ />
403
+ <p
404
+ role="alert"
405
+ style={{
406
+ margin: 'var(--spacing-small) 0 0',
407
+ color: 'var(--form-field-text-error-color-error-text)',
408
+ fontSize: 'var(--font-size-2-13)',
409
+ }}
410
+ >
411
+ Please select a notification method to continue.
412
+ </p>
413
+ </div>
414
+ );
415
+ };
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Stories
419
+ // ---------------------------------------------------------------------------
48
420
 
49
- // Default radio button
50
421
  export const Default: Story = {
51
422
  args: {
52
- name: 'example',
53
- value: 'option1',
54
- label: 'Option 1',
423
+ name: 'report-period',
424
+ value: 'current-term',
425
+ label: 'Current term',
55
426
  checked: false,
427
+ disabled: false,
428
+ hasError: false,
56
429
  },
430
+ render: args => (
431
+ <div style={{ padding: 'var(--spacing-xlarge)' }}>
432
+ <RadioButtonInput {...args} />
433
+ </div>
434
+ ),
57
435
  };
58
436
 
59
- // Checked radio button
60
- export const Checked: Story = {
61
- args: {
62
- name: 'example',
63
- value: 'option1',
64
- label: 'Option 1',
65
- checked: true,
437
+ export const Checked: Story = withDescription(
438
+ {
439
+ render: () => (
440
+ <div style={{ padding: 'var(--spacing-xlarge)' }}>
441
+ <RadioButtonInput
442
+ id="checked-example"
443
+ name="report-period"
444
+ value="current-term"
445
+ checked
446
+ onChange={fn()}
447
+ label="Current term"
448
+ />
449
+ </div>
450
+ ),
451
+ parameters: {
452
+ docs: {
453
+ source: {
454
+ language: 'tsx',
455
+ code: `
456
+ import { RadioButtonInput } from '@arbor-education/design-system.components';
457
+
458
+ function CheckedExample() {
459
+ return (
460
+ <RadioButtonInput
461
+ id="report-current-term"
462
+ name="report-period"
463
+ value="current-term"
464
+ checked
465
+ onChange={(e) => console.log(e.target.value)}
466
+ label="Current term"
467
+ />
468
+ );
469
+ }
470
+
471
+ export default CheckedExample;
472
+ `.trim(),
473
+ },
474
+ },
475
+ },
66
476
  },
67
- };
477
+ 'The selected (checked) state — the indicator circle fills with a solid inner dot. `checked` is always controlled; pair it with an `onChange` handler so the user can change the selection.',
478
+ );
68
479
 
69
- // Disabled radio button
70
- export const Disabled: Story = {
71
- args: {
72
- name: 'example',
73
- value: 'option1',
74
- label: 'Option 1',
75
- disabled: true,
480
+ export const Disabled: Story = withDescription(
481
+ {
482
+ render: () => (
483
+ <div style={{ padding: 'var(--spacing-xlarge)' }}>
484
+ <RadioButtonInput
485
+ id="disabled-example"
486
+ name="report-period"
487
+ value="last-term"
488
+ checked={false}
489
+ disabled
490
+ onChange={fn()}
491
+ label="Last term"
492
+ />
493
+ </div>
494
+ ),
495
+ parameters: {
496
+ docs: {
497
+ source: {
498
+ language: 'tsx',
499
+ code: `
500
+ import { RadioButtonInput } from '@arbor-education/design-system.components';
501
+
502
+ function DisabledExample() {
503
+ // Disabled — pair with a Tooltip or helper text explaining why.
504
+ return (
505
+ <RadioButtonInput
506
+ id="report-last-term"
507
+ name="report-period"
508
+ value="last-term"
509
+ checked={false}
510
+ disabled
511
+ onChange={() => {}}
512
+ label="Last term"
513
+ />
514
+ );
515
+ }
516
+
517
+ export default DisabledExample;
518
+ `.trim(),
519
+ },
520
+ },
521
+ },
76
522
  },
77
- };
523
+ [
524
+ 'A disabled, unselected radio button. The control renders with muted styling and the cursor changes to `not-allowed`.',
525
+ 'The field is removed from the tab order and its value is not submitted in a form.',
526
+ 'Always accompany a disabled radio with a tooltip or visible helper text that explains why it is',
527
+ 'disabled — for example: "Last term data is not yet available."',
528
+ ].join(' '),
529
+ );
78
530
 
79
- // Disabled and checked
80
- export const DisabledChecked: Story = {
81
- args: {
82
- name: 'example',
83
- value: 'option1',
84
- label: 'Option 1',
85
- disabled: true,
86
- checked: true,
531
+ export const DisabledChecked: Story = withDescription(
532
+ {
533
+ render: () => (
534
+ <div style={{ padding: 'var(--spacing-xlarge)' }}>
535
+ <RadioButtonInput
536
+ id="disabled-checked-example"
537
+ name="report-period"
538
+ value="current-term"
539
+ checked
540
+ disabled
541
+ onChange={fn()}
542
+ label="Current term"
543
+ />
544
+ </div>
545
+ ),
546
+ parameters: {
547
+ docs: {
548
+ source: {
549
+ language: 'tsx',
550
+ code: `
551
+ import { RadioButtonInput } from '@arbor-education/design-system.components';
552
+
553
+ function DisabledCheckedExample() {
554
+ // Disabled and already selected — read-only enforced selection.
555
+ return (
556
+ <RadioButtonInput
557
+ id="report-current-term"
558
+ name="report-period"
559
+ value="current-term"
560
+ checked
561
+ disabled
562
+ onChange={() => {}}
563
+ label="Current term"
564
+ />
565
+ );
566
+ }
567
+
568
+ export default DisabledCheckedExample;
569
+ `.trim(),
570
+ },
571
+ },
572
+ },
87
573
  },
88
- };
574
+ 'A disabled radio button that is already selected. This is appropriate for read-only settings where the current selection is system-enforced and cannot be changed. Always provide a tooltip or helper text explaining the constraint.',
575
+ );
576
+
577
+ export const WithError: Story = withDescription(
578
+ {
579
+ render: () => (
580
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-medium)' }}>
581
+ <p style={{ margin: 0, color: 'var(--color-grey-600)', fontSize: 'var(--font-size-2-13)' }}>
582
+ Report period
583
+ </p>
584
+ <RadioButtonInput
585
+ id="error-current-term"
586
+ name="error-report-period"
587
+ value="current-term"
588
+ checked={false}
589
+ hasError
590
+ onChange={fn()}
591
+ label="Current term"
592
+ />
593
+ <RadioButtonInput
594
+ id="error-last-term"
595
+ name="error-report-period"
596
+ value="last-term"
597
+ checked={false}
598
+ hasError
599
+ onChange={fn()}
600
+ label="Last term"
601
+ />
602
+ <RadioButtonInput
603
+ id="error-full-year"
604
+ name="error-report-period"
605
+ value="full-year"
606
+ checked={false}
607
+ hasError
608
+ onChange={fn()}
609
+ label="Full academic year"
610
+ />
611
+ <p
612
+ role="alert"
613
+ style={{
614
+ margin: 0,
615
+ color: 'var(--form-field-text-error-color-error-text)',
616
+ fontSize: 'var(--font-size-2-13)',
617
+ }}
618
+ >
619
+ Please select a report period to continue.
620
+ </p>
621
+ </div>
622
+ ),
623
+ parameters: {
624
+ docs: {
625
+ source: {
626
+ language: 'tsx',
627
+ code: `
628
+ import { RadioButtonInput } from '@arbor-education/design-system.components';
629
+
630
+ function WithErrorExample() {
631
+ // IMPORTANT: hasError sets aria-invalid="true" on the <input> but makes
632
+ // NO visual change to the indicator circle — there are zero CSS rules for
633
+ // the error state. You MUST render the error message yourself.
634
+ return (
635
+ <div>
636
+ <RadioButtonInput
637
+ id="period-current"
638
+ name="report-period"
639
+ value="current-term"
640
+ checked={false}
641
+ hasError
642
+ onChange={() => {}}
643
+ label="Current term"
644
+ />
645
+ <RadioButtonInput
646
+ id="period-last"
647
+ name="report-period"
648
+ value="last-term"
649
+ checked={false}
650
+ hasError
651
+ onChange={() => {}}
652
+ label="Last term"
653
+ />
654
+ <RadioButtonInput
655
+ id="period-full"
656
+ name="report-period"
657
+ value="full-year"
658
+ checked={false}
659
+ hasError
660
+ onChange={() => {}}
661
+ label="Full academic year"
662
+ />
663
+ {/* Consumer-rendered error message — hasError alone shows nothing visually */}
664
+ <p role="alert" style={{ color: 'var(--form-field-text-error-color-error-text)' }}>
665
+ Please select a report period to continue.
666
+ </p>
667
+ </div>
668
+ );
669
+ }
670
+
671
+ export default WithErrorExample;
672
+ `.trim(),
673
+ },
674
+ },
675
+ },
676
+ },
677
+ [
678
+ '**Important:** `hasError={true}` sets `aria-invalid="true"` on the native `<input>` — this is',
679
+ 'ARIA-only and makes zero visual difference to the indicator circle.',
680
+ 'There are no CSS rules in the design system that change the indicator\'s appearance based on `aria-invalid`.',
681
+ 'To communicate the error visually, you must render your own error message below the group (shown here',
682
+ 'with `role="alert"` so screen readers announce it automatically).',
683
+ 'Error validation should always be at the field level, not per-option.',
684
+ ].join(' '),
685
+ );
686
+
687
+ export const WithoutLabel: Story = withDescription(
688
+ {
689
+ render: () => (
690
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-medium)', alignItems: 'flex-start' }}>
691
+ <p style={{ margin: 0, color: 'var(--color-grey-600)', fontSize: 'var(--font-size-2-13)' }}>
692
+ Unselected (no label — indicator only):
693
+ </p>
694
+ <RadioButtonInput
695
+ id="no-label-unselected"
696
+ name="no-label-group"
697
+ value="option-a"
698
+ checked={false}
699
+ onChange={fn()}
700
+ aria-label="Option A"
701
+ />
702
+ <p style={{ margin: 0, color: 'var(--color-grey-600)', fontSize: 'var(--font-size-2-13)' }}>
703
+ Selected (no label):
704
+ </p>
705
+ <RadioButtonInput
706
+ id="no-label-selected"
707
+ name="no-label-group"
708
+ value="option-b"
709
+ checked
710
+ onChange={fn()}
711
+ aria-label="Option B"
712
+ />
713
+ </div>
714
+ ),
715
+ parameters: {
716
+ docs: {
717
+ source: {
718
+ language: 'tsx',
719
+ code: `
720
+ import { RadioButtonInput } from '@arbor-education/design-system.components';
721
+
722
+ function WithoutLabelExample() {
723
+ // When there is no visible label, always provide aria-label for screen readers.
724
+ return (
725
+ <RadioButtonInput
726
+ id="option-a"
727
+ name="my-group"
728
+ value="option-a"
729
+ checked={isSelected}
730
+ onChange={(e) => setSelected(e.target.value)}
731
+ aria-label="Option A"
732
+ />
733
+ );
734
+ }
735
+
736
+ export default WithoutLabelExample;
737
+ `.trim(),
738
+ },
739
+ },
740
+ },
741
+ },
742
+ [
743
+ 'RadioButtonInput without a `label` prop — renders just the indicator circle with no adjacent text.',
744
+ 'When omitting `label`, always pass `aria-label` or `aria-labelledby` so screen reader users',
745
+ 'understand what each option represents.',
746
+ ].join(' '),
747
+ );
748
+
749
+ export const AllStates: Story = withDescription(
750
+ {
751
+ render: () => (
752
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-medium)' }}>
753
+ <p style={{ margin: 0, color: 'var(--color-grey-600)', fontSize: 'var(--font-size-2-13)' }}>
754
+ Enabled states:
755
+ </p>
756
+ <RadioButtonInput
757
+ id="all-unselected"
758
+ name="all-states-group"
759
+ value="unselected"
760
+ checked={false}
761
+ onChange={fn()}
762
+ label="Unselected — default state"
763
+ />
764
+ <RadioButtonInput
765
+ id="all-selected"
766
+ name="all-states-group"
767
+ value="selected"
768
+ checked
769
+ onChange={fn()}
770
+ label="Selected — inner dot filled"
771
+ />
772
+ <p style={{ margin: 'var(--spacing-small) 0 0', color: 'var(--color-grey-600)', fontSize: 'var(--font-size-2-13)' }}>
773
+ Disabled states:
774
+ </p>
775
+ <RadioButtonInput
776
+ id="all-disabled-unselected"
777
+ name="all-disabled-group"
778
+ value="disabled-unselected"
779
+ checked={false}
780
+ disabled
781
+ onChange={fn()}
782
+ label="Disabled unselected"
783
+ />
784
+ <RadioButtonInput
785
+ id="all-disabled-selected"
786
+ name="all-disabled-group"
787
+ value="disabled-selected"
788
+ checked
789
+ disabled
790
+ onChange={fn()}
791
+ label="Disabled selected"
792
+ />
793
+ </div>
794
+ ),
795
+ parameters: {
796
+ docs: {
797
+ source: {
798
+ language: 'tsx',
799
+ code: `
800
+ import { RadioButtonInput } from '@arbor-education/design-system.components';
89
801
 
90
- // Radio button group example
91
- export const RadioGroup: Story = {
92
- render: () => {
93
- const options = [{ label: 'Option 1', value: 'option1' }, { label: 'Option 2', value: 'option2' }, { label: 'Option 3', value: 'option3' }];
94
- const [checkedValue, setCheckedValue] = useState<string>(options[0]?.value ?? '');
802
+ function AllStatesExample() {
803
+ // Static display of all four visual states.
804
+ // In a real application every RadioButtonInput needs an onChange handler.
805
+ return (
806
+ <>
807
+ {/* Enabled */}
808
+ <RadioButtonInput name="g1" value="a" checked={false} onChange={() => {}} label="Unselected" />
809
+ <RadioButtonInput name="g1" value="b" checked onChange={() => {}} label="Selected" />
810
+ {/* Disabled */}
811
+ <RadioButtonInput name="g2" value="c" checked={false} disabled onChange={() => {}} label="Disabled unselected" />
812
+ <RadioButtonInput name="g2" value="d" checked disabled onChange={() => {}} label="Disabled selected" />
813
+ </>
814
+ );
815
+ }
95
816
 
96
- return (
817
+ export default AllStatesExample;
818
+ `.trim(),
819
+ },
820
+ },
821
+ },
822
+ },
823
+ 'A side-by-side reference showing all four visual states: unselected, selected, disabled unselected, and disabled selected. There is no visual error state on the indicator circle — `hasError` is ARIA-only. See the `WithError` story for the correct error pattern.',
824
+ );
825
+
826
+ export const RadioGroup: Story = withDescription(
827
+ {
828
+ render: () => <RadioGroupTemplate />,
829
+ parameters: {
830
+ docs: {
831
+ source: {
832
+ language: 'tsx',
833
+ code: `
834
+ import { useState } from 'react';
835
+ import { RadioButtonGroup } from '@arbor-education/design-system.components';
836
+
837
+ function AttendanceGroupingExample() {
838
+ const [grouping, setGrouping] = useState('year-group');
839
+
840
+ return (
841
+ <RadioButtonGroup
842
+ legend="Attendance grouping"
843
+ name="attendance-grouping"
844
+ checkedValue={grouping}
845
+ onChange={(e) => setGrouping(e.target.value)}
846
+ options={[
847
+ { id: 'opt-custom', value: 'custom-group', label: 'Custom group' },
848
+ { id: 'opt-year', value: 'year-group', label: 'Year group' },
849
+ { id: 'opt-reg', value: 'reg-form', label: 'Registration form' },
850
+ ]}
851
+ />
852
+ );
853
+ }
854
+
855
+ export default AttendanceGroupingExample;
856
+ `.trim(),
857
+ },
858
+ },
859
+ },
860
+ },
861
+ [
862
+ '`RadioButtonGroup` is the canonical way to render a set of radio buttons.',
863
+ 'It composes `Fieldset` (which renders a `<fieldset>` + `<legend>`) and maps `options` to individual `RadioButtonInput` elements.',
864
+ 'The `legend` prop is announced by screen readers as the group label before each option.',
865
+ 'A single `checkedValue` + `onChange` at the group level manages the selection state — the group',
866
+ 'compares `checkedValue` against each option\'s `value` to determine which radio is selected.',
867
+ 'Try selecting different options — the state display below updates in real time.',
868
+ ].join(' '),
869
+ );
870
+
871
+ export const RadioGroupDisabled: Story = withDescription(
872
+ {
873
+ name: 'RadioGroup — Fieldset-level disabled',
874
+ render: () => (
875
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
876
+ <RadioButtonGroup
877
+ legend="Report period (locked — academic year not yet published)"
878
+ name="disabled-report-period"
879
+ checkedValue="current-term"
880
+ onChange={fn()}
881
+ disabled
882
+ options={[
883
+ { id: 'dis-opt-current', value: 'current-term', label: 'Current term' },
884
+ { id: 'dis-opt-last', value: 'last-term', label: 'Last term' },
885
+ { id: 'dis-opt-full', value: 'full-year', label: 'Full academic year' },
886
+ ]}
887
+ />
888
+ </div>
889
+ ),
890
+ parameters: {
891
+ docs: {
892
+ source: {
893
+ language: 'tsx',
894
+ code: `
895
+ import { RadioButtonGroup } from '@arbor-education/design-system.components';
896
+
897
+ function DisabledGroupExample() {
898
+ // Passing disabled to RadioButtonGroup flows through Fieldset's HTMLFieldSetElement props,
899
+ // disabling ALL child radio buttons at once — native HTML behaviour, zero extra JavaScript.
900
+ return (
901
+ <RadioButtonGroup
902
+ legend="Report period (locked — academic year not yet published)"
903
+ name="report-period"
904
+ checkedValue="current-term"
905
+ onChange={() => {}}
906
+ disabled
907
+ options={[
908
+ { id: 'opt-current', value: 'current-term', label: 'Current term' },
909
+ { id: 'opt-last', value: 'last-term', label: 'Last term' },
910
+ { id: 'opt-full', value: 'full-year', label: 'Full academic year' },
911
+ ]}
912
+ />
913
+ );
914
+ }
915
+
916
+ export default DisabledGroupExample;
917
+ `.trim(),
918
+ },
919
+ },
920
+ },
921
+ },
922
+ [
923
+ 'Passing `disabled` to `RadioButtonGroup` flows through the underlying `<fieldset>` element',
924
+ '(via `Fieldset`\'s `HTMLFieldSetElement` props), which disables ALL child `<input>` elements at once —',
925
+ 'native HTML behaviour, zero extra JavaScript.',
926
+ 'This is the correct pattern for locking an entire radio group (e.g. a settings panel that is',
927
+ 'read-only until the academic year is published).',
928
+ ].join(' '),
929
+ );
930
+
931
+ export const RadioGroupWithError: Story = withDescription(
932
+ {
933
+ name: 'RadioGroup — field-level error',
934
+ render: () => <RadioGroupWithErrorTemplate />,
935
+ parameters: {
936
+ docs: {
937
+ source: {
938
+ language: 'tsx',
939
+ code: `
940
+ import { useState } from 'react';
941
+ import { RadioButtonGroup } from '@arbor-education/design-system.components';
942
+
943
+ function NotificationMethodWithErrorExample() {
944
+ const [method, setMethod] = useState('');
945
+
946
+ // hasError in each option sets aria-invalid="true" on each <input>.
947
+ // This makes ZERO visual difference to the indicator circles — there are
948
+ // no CSS rules for the aria-invalid error state. The visible error message
949
+ // below is the only thing that communicates the error to sighted users.
950
+ return (
951
+ <div>
97
952
  <RadioButtonGroup
98
- legend="Radio group"
99
- name="group"
100
- options={options}
101
- checkedValue={checkedValue}
102
- onChange={e => setCheckedValue(e.target.value)}
953
+ legend="Notification method"
954
+ name="notification-method"
955
+ checkedValue={method}
956
+ onChange={(e) => setMethod(e.target.value)}
957
+ options={[
958
+ { id: 'opt-email', value: 'email', label: 'Email', hasError: true },
959
+ { id: 'opt-sms', value: 'sms', label: 'SMS', hasError: true },
960
+ { id: 'opt-post', value: 'post', label: 'Post', hasError: true },
961
+ ]}
103
962
  />
104
- );
963
+ {/* Consumer-rendered error message — hasError alone shows nothing visually */}
964
+ <p role="alert" style={{ color: 'var(--form-field-text-error-color-error-text)' }}>
965
+ Please select a notification method to continue.
966
+ </p>
967
+ </div>
968
+ );
969
+ }
970
+
971
+ export default NotificationMethodWithErrorExample;
972
+ `.trim(),
973
+ },
974
+ },
975
+ },
105
976
  },
106
- };
977
+ [
978
+ '**Field-level validation** — error state is shown at the group level, not per option.',
979
+ '`hasError` is passed to each option in the `options` array, which sets `aria-invalid="true"`',
980
+ 'on each native `<input>`. Screen readers will announce each option as invalid.',
981
+ '**However, `hasError` makes ZERO visual change to the indicator circles** — there are no CSS rules',
982
+ 'for the error state. The visible error message rendered below the group (with `role="alert"`) is',
983
+ 'the only thing that communicates the error to sighted users.',
984
+ 'Start with no option selected and note how the error message is present — select any option to',
985
+ 'resolve the validation in a real implementation.',
986
+ ].join(' '),
987
+ );