@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,181 +1,1598 @@
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';
2
11
  import { fn } from 'storybook/test';
3
-
4
12
  import { comboboxPeopleOptions } from '../../mocks/comboboxStoryOptions';
5
13
  import { FormField } from './FormField';
6
14
 
7
- const meta: Meta<typeof FormField> = {
15
+ // ---------------------------------------------------------------------------
16
+ // Docs page content
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const DESCRIPTION_INTRO = [
20
+ 'FormField is the **complete form field assembly**: it wires together a label, an optional',
21
+ 'description, any of eight input controls, an optional helper link, and an error message — all',
22
+ 'with the correct accessibility connections built in automatically.',
23
+ '',
24
+ 'Think of it as a filing cabinet drawer with everything pre-labelled: you tell it what kind of',
25
+ 'input you need (`inputType`), give it a label and an `id`, and it handles the `htmlFor`,',
26
+ '`aria-describedby`, `aria-invalid`, and `hasError` wiring for you.',
27
+ ].join('\n');
28
+
29
+ const USAGE_GUIDANCE = [
30
+ '### When to use',
31
+ '',
32
+ '- Any labelled input in an Arbor form — enrolment, student records, timetabling, attendance',
33
+ '- When you need built-in error state management without writing the aria connections yourself',
34
+ '- When a field needs a helper link to external documentation or a field description below the label',
35
+ '',
36
+ '---',
37
+ '',
38
+ '### When NOT to use',
39
+ '',
40
+ '| Scenario | Use instead |',
41
+ '|---|---|',
42
+ '| Raw input without a label | Use the individual input component (`TextInput`, etc.) directly |',
43
+ '| Grouping several related fields (address, name) | Wrap multiple `FormField`s in a `Fieldset` |',
44
+ '| Non-standard input control | Compose the label + input yourself using `Label` + the input component |',
45
+ '',
46
+ '---',
47
+ '',
48
+ '### The eight input types',
49
+ '',
50
+ '| `inputType` | Renders | Typical use |',
51
+ '|---|---|---|',
52
+ '| `"text"` (default) | `TextInput` | Names, codes, short free text |',
53
+ '| `"textarea"` | `TextArea` | Notes, medical conditions, long free text |',
54
+ '| `"number"` | `NumberInput` | Class sizes, ages, counts |',
55
+ '| `"time"` | `TimeInput` | Registration time, period start/end |',
56
+ '| `"colourPicker"` | `ColourPickerDropdown` | Form group or subject colour |',
57
+ '| `"selectDropdown"` | `SelectDropdown` | Year group, term, single or multi select |',
58
+ '| `"datePicker"` | `DatePicker` | Date of birth, enrolment date |',
59
+ '| `"combobox"` | `Combobox` | Searchable people picker, tutor assignment |',
60
+ ].join('\n');
61
+
62
+ const DEVELOPER_NOTES = [
63
+ '### Critical gotchas — read before using',
64
+ '',
65
+ '**1. Always provide `id`.** The `id` prop drives `label[htmlFor]`, and the `aria-describedby`',
66
+ 'IDs for the description and error spans. Omit it and the a11y wiring silently breaks — no TS',
67
+ 'error, just inaccessible markup. Treat it as required.',
68
+ '',
69
+ '**2. Never set `hasError`, `aria-invalid`, or `aria-describedby` in `inputProps`.** FormField',
70
+ 'builds these automatically from `errorText` and `fieldDescription`. If you set them in',
71
+ '`inputProps`, they will be overridden — but the intent becomes confusing and may produce',
72
+ 'conflicting values depending on spread order.',
73
+ '',
74
+ '**3. `helperLinkText` and `helperLinkUrl` must both be provided.** Providing only one renders',
75
+ 'nothing — the component renders neither the link text nor the URL if the pair is incomplete.',
76
+ '',
77
+ '**4. `helperLink` and `fieldDescription` are mutually exclusive by design.** The component',
78
+ 'will render both if you supply both, but the Confluence design spec prohibits this combination.',
79
+ 'Use one or the other, never both.',
80
+ '',
81
+ '**5. `errorText` and `helperLink` CAN coexist.** Both appear inside the same `ds-form-field__message`',
82
+ 'container: error rendered first, helper link second (stacked vertically).',
83
+ '',
84
+ '**6. `fieldDescription` persists during errors.** The description span stays visible even when',
85
+ '`errorText` is set. It is not hidden or replaced by the error.',
86
+ '',
87
+ '**7. Always provide `label` when using `helperLinkText`.** The helper link `aria-label` is',
88
+ 'constructed as `"${label} helper link"`. Omit `label` and the aria-label becomes `"undefined helper link"`.',
89
+ '',
90
+ '**8. Optional fields: use the label suffix convention.** There is no `required` prop and no',
91
+ 'asterisk pattern. Mark optional fields by appending `"(optional)"` to the label text, e.g.',
92
+ '`label="Middle name (optional)"`. All fields without this suffix are implicitly required.',
93
+ '',
94
+ '**9. `NumberInput` renders `type="text"` internally.** Do not pass `type` in `inputProps` when',
95
+ '`inputType="number"` — it is ignored and may produce confusing behaviour. The component uses',
96
+ '`inputMode="numeric"` for mobile keyboards.',
97
+ '',
98
+ '**10. `TimeInput` switches to Combobox mode when `options` is provided.** Pass an array of',
99
+ '`"HH:MM"` strings to `inputProps.options` to replace the native time picker with a searchable',
100
+ 'dropdown of preset times.',
101
+ '',
102
+ '**11. The helper link does NOT open in a new tab.** There is no `target="_blank"` on the anchor.',
103
+ 'This is a known limitation — Confluence guidance says it should open a new tab, but the current',
104
+ 'implementation does not do this. Bear it in mind when linking to external documentation.',
105
+ '',
106
+ '---',
107
+ '',
108
+ '### Accessibility',
109
+ '',
110
+ '- `id` drives all ARIA wiring — treat it as required even though TypeScript allows it to be omitted',
111
+ '- `fieldDescription` is connected via `aria-describedby` automatically — never wire this yourself',
112
+ '- `errorText` sets both `hasError` and `aria-invalid` on the inner input, and a description via `aria-describedby`',
113
+ '- The error span contains a `triangle-alert` icon; the icon is decorative (the text carries the meaning)',
114
+ '- Visible labels are always required — never rely on placeholder text as a substitute for a label',
115
+ '- For grouped fields (address blocks, name fields) wrap in `Fieldset` with a `legend`',
116
+ '',
117
+ '---',
118
+ '',
119
+ '### TypeScript types',
120
+ '',
121
+ '```ts',
122
+ "import { FormField } from '@arbor-education/design-system.components';",
123
+ '',
124
+ 'function MyField(props: FormField.Props) { ... }',
125
+ '```',
126
+ '',
127
+ '| Type | Description |',
128
+ '|---|---|',
129
+ '| `FormField.Props` | Full props interface |',
130
+ ].join('\n');
131
+
132
+ const RELATED_COMPONENTS = [
133
+ '## Related components',
134
+ '',
135
+ '[TextInput](?path=/docs/components-formfield-inputs-textinput--docs) · ',
136
+ '[TextArea](?path=/docs/components-formfield-inputs-textarea--docs) · ',
137
+ '[NumberInput](?path=/docs/components-formfield-inputs-numeric--docs) · ',
138
+ '[TimeInput](?path=/docs/components-formfield-inputs-timeinput--docs) · ',
139
+ '[SelectDropdown](?path=/docs/components-formfield-inputs-selectdropdown--docs) · ',
140
+ '[ColourPickerDropdown](?path=/docs/components-formfield-inputs-colourpickerdropdown--docs) · ',
141
+ '[DatePicker](?path=/docs/components-datepicker--docs) · ',
142
+ '[Combobox](?path=/docs/components-combobox--docs) · ',
143
+ '[Fieldset](?path=/docs/components-formfield-fieldset--docs) · ',
144
+ '[Label](?path=/docs/components-formfield-inputs-label--docs)',
145
+ ].join('\n');
146
+
147
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
148
+
149
+ function FormFieldDocsPage() {
150
+ return (
151
+ <>
152
+ <Title />
153
+ <Subtitle />
154
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
155
+ <DocHeading>Interactive example</DocHeading>
156
+ <Markdown>{PROPS_INTRO}</Markdown>
157
+ <DocPrimary />
158
+ <Controls />
159
+ <DocHeading>Usage guidance</DocHeading>
160
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
161
+ <DocHeading>Developer notes</DocHeading>
162
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
163
+ <DocHeading>Examples</DocHeading>
164
+ <Stories title="" />
165
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
166
+ </>
167
+ );
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Meta
172
+ // ---------------------------------------------------------------------------
173
+
174
+ const meta = {
8
175
  title: 'Components/FormField',
9
176
  component: FormField,
10
- };
11
-
12
- export const Default = {
13
- args: {
14
- id: 'text-input',
15
- label: 'Text Input',
16
- inputProps: {
17
- placeholder: 'Enter your text',
18
- onChange: fn(),
177
+ tags: ['autodocs'],
178
+ parameters: {
179
+ docs: {
180
+ page: FormFieldDocsPage,
19
181
  },
20
- helperLinkText: 'More information',
21
- helperLinkUrl: 'https://www.google.com',
22
- errorText: 'This is some error text',
23
- fieldDescription: 'This is some descriptive text for the field',
24
- inputType: 'text',
25
182
  },
26
183
  argTypes: {
27
- 'helperLinkText': {
184
+ id: {
28
185
  control: 'text',
29
- description: 'Helper link text',
186
+ description: [
187
+ '**Practically required.** Drives `label[htmlFor]` and the `aria-describedby` IDs for the',
188
+ 'field description (`{id}-description`) and error message (`{id}-error`).',
189
+ 'Omit it and all accessibility wiring silently breaks — no TypeScript error, just broken a11y.',
190
+ ].join(' '),
191
+ table: {
192
+ type: { summary: 'string' },
193
+ },
30
194
  },
31
- 'helperLinkUrl': {
195
+ label: {
32
196
  control: 'text',
33
- description: 'Helper link URL',
197
+ description: [
198
+ 'Visible label rendered above the input via the `Label` component (`ds-label`).',
199
+ 'Short, sentence case. Use `"Field name (optional)"` suffix for optional fields — there is',
200
+ 'no `required` prop and no asterisk convention.',
201
+ '**Also required when using `helperLinkText`** — the link\'s `aria-label` uses this value verbatim.',
202
+ ].join(' '),
203
+ table: {
204
+ type: { summary: 'string' },
205
+ },
34
206
  },
35
- 'errorText': {
36
- control: 'text',
37
- description: 'Error text',
207
+ inputType: {
208
+ control: 'select',
209
+ options: ['text', 'textarea', 'number', 'time', 'colourPicker', 'selectDropdown', 'datePicker', 'combobox'],
210
+ description: [
211
+ 'Determines which input component is rendered.',
212
+ 'Defaults to `"text"` (renders `TextInput`).',
213
+ '`"textarea"` → `TextArea` (auto-grows); `"number"` → `NumberInput` (spinner buttons + decimal);',
214
+ '`"time"` → `TimeInput` (native picker or combobox preset mode);',
215
+ '`"colourPicker"` → `ColourPickerDropdown` (hex colour sketch picker in a dropdown);',
216
+ '`"selectDropdown"` → `SelectDropdown` (single or multi select with grouping);',
217
+ '`"datePicker"` → `DatePicker` (calendar popover);',
218
+ '`"combobox"` → `Combobox` (searchable, type-ahead).',
219
+ ].join(' '),
220
+ table: {
221
+ type: { summary: "'text' | 'textarea' | 'number' | 'time' | 'colourPicker' | 'selectDropdown' | 'datePicker' | 'combobox'" },
222
+ defaultValue: { summary: "'text'" },
223
+ },
38
224
  },
39
- 'fieldDescription': {
225
+ fieldDescription: {
40
226
  control: 'text',
41
- description: 'Field description',
227
+ description: [
228
+ 'Static guidance rendered below the label, above the input.',
229
+ 'Automatically connected to the input via `aria-describedby`.',
230
+ 'Target 20–60 characters. Good examples: `"Shown on the parent-facing report"`,',
231
+ '`"Up to 30 characters"`, `"Format: DD/MM/YYYY"`.',
232
+ '**Stays visible when `errorText` is set** — it is not replaced by the error.',
233
+ '**Mutually exclusive with `helperLinkText` / `helperLinkUrl`** per Confluence design spec.',
234
+ ].join(' '),
235
+ table: {
236
+ type: { summary: 'ReactNode' },
237
+ },
42
238
  },
43
- 'inputType': {
44
- control: 'select',
45
- options: ['text', 'textarea', 'number', 'time', 'colourPicker', 'selectDropdown', 'datePicker', 'combobox'],
46
- description: 'Input type',
239
+ helperLinkText: {
240
+ control: 'text',
241
+ description: [
242
+ 'Text for an external documentation link rendered below the input.',
243
+ '**Both `helperLinkText` AND `helperLinkUrl` must be provided** — supplying only one renders nothing.',
244
+ 'Use action-led copy: `"Learn more"`, `"View guidance"`, `"See SEN framework"`.',
245
+ '**Mutually exclusive with `fieldDescription`** per Confluence design spec.',
246
+ '**Known limitation:** the link has no `target="_blank"` and does not open in a new tab.',
247
+ ].join(' '),
248
+ table: {
249
+ type: { summary: 'string' },
250
+ },
47
251
  },
48
- 'inputProps.size': {
49
- control: 'select',
50
- options: ['M', 'S'],
51
- description: 'Input size',
252
+ helperLinkUrl: {
253
+ control: 'text',
254
+ description: [
255
+ 'URL for the helper link. **Must be paired with `helperLinkText`** — supplying only one renders nothing.',
256
+ 'Typically points to Confluence guidance or external documentation.',
257
+ '**Known limitation:** no `target="_blank"` — does not open in a new tab.',
258
+ ].join(' '),
259
+ table: {
260
+ type: { summary: 'string' },
261
+ },
52
262
  },
53
- 'inputProps.disabled': {
54
- control: 'boolean',
55
- description: 'Disable the input',
263
+ errorText: {
264
+ control: 'text',
265
+ description: [
266
+ 'Error message rendered below the input with a `triangle-alert` icon.',
267
+ 'Setting this automatically applies `hasError` and `aria-invalid` to the inner input,',
268
+ 'and adds an `aria-describedby` reference to the error span.',
269
+ 'Write errors as: what went wrong + how to fix it.',
270
+ 'Example: `"Enter a date in DD/MM/YYYY format"`. One error per field.',
271
+ ].join(' '),
272
+ table: {
273
+ type: { summary: 'string' },
274
+ },
56
275
  },
57
- 'inputProps.placeholder': {
276
+ className: {
58
277
  control: 'text',
59
- description: 'Input placeholder text',
278
+ description: [
279
+ 'Additional CSS class(es) applied to the **root `div`** (`ds-form-field`), not the inner input.',
280
+ 'Use for layout overrides only — do not use to style the input itself.',
281
+ ].join(' '),
282
+ table: {
283
+ type: { summary: 'string' },
284
+ },
285
+ },
286
+ inputProps: {
287
+ control: false,
288
+ description: [
289
+ 'Props spread onto the inner input component. The shape narrows based on `inputType`.',
290
+ '**Do NOT set `hasError`, `aria-invalid`, or `aria-describedby` here** — FormField builds',
291
+ 'these automatically. They are spread BEFORE `inputProps` and will be overridden.',
292
+ 'The Controls panel cannot represent this discriminated union — configure it in the story code.',
293
+ ].join(' '),
294
+ table: {
295
+ type: { summary: 'TextInputProps | TextAreaProps | NumberInputProps | TimeInputProps | ColourPickerDropdownProps | SelectDropdownInputProps | DatePickerProps | ComboboxProps' },
296
+ },
60
297
  },
61
298
  },
62
- };
299
+ } satisfies Meta<typeof FormField>;
63
300
 
64
- type Story = StoryObj<typeof meta>;
301
+ export default meta;
65
302
 
66
- // Form example with multiple inputs
67
- export const FormExample: Story = {
68
- render: () => (
69
- <div style={{ width: '400px', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
303
+ // Use StoryObj<typeof FormField> (not typeof meta) so render-only stories are not
304
+ // forced to provide args for the required discriminated-union props.
305
+ type Story = StoryObj<typeof FormField>;
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Helper: attach a per-story description to docs
309
+ // ---------------------------------------------------------------------------
310
+
311
+ const withDescription = (story: Story, description: string): Story => ({
312
+ ...story,
313
+ parameters: {
314
+ ...story.parameters,
315
+ docs: {
316
+ ...story.parameters?.docs,
317
+ description: {
318
+ story: description,
319
+ },
320
+ },
321
+ },
322
+ });
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Stories
326
+ // ---------------------------------------------------------------------------
327
+
328
+ export const Default: Story = withDescription(
329
+ {
330
+ args: {
331
+ id: 'student-surname',
332
+ label: 'Surname',
333
+ inputType: 'text',
334
+ inputProps: {
335
+ placeholder: 'e.g. Nylund',
336
+ },
337
+ },
338
+ render: args => <FormField {...args} />,
339
+ },
340
+ 'The interactive canvas. Every top-level prop is wired to the Controls panel below. Use the controls to toggle `errorText`, add a `fieldDescription`, switch `inputType`, or add a `helperLinkUrl`. **Note:** `inputProps` cannot be controlled from the panel — it represents a discriminated union that changes shape with `inputType`. Edit the story code to configure it.',
341
+ );
342
+
343
+ export const WithFieldDescription: Story = withDescription(
344
+ {
345
+ parameters: {
346
+ docs: {
347
+ source: {
348
+ language: 'tsx',
349
+ code: `
350
+ import { FormField } from '@arbor-education/design-system.components';
351
+
352
+ function WithFieldDescriptionExample() {
353
+ return (
354
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
70
355
  <FormField
71
- id="first-name"
72
- label="First Name"
73
- inputProps={{
74
- placeholder: 'Enter your first name',
75
- }}
356
+ id="preferred-name"
357
+ label="Preferred name (optional)"
358
+ fieldDescription="Shown on the parent-facing report card"
359
+ inputProps={{ placeholder: 'e.g. Rose' }}
76
360
  />
77
361
  <FormField
78
- id="last-name"
79
- label="Last Name"
362
+ id="medical-notes"
363
+ label="Medical conditions"
364
+ fieldDescription="Up to 30 characters. Visible to form tutors only."
365
+ inputType="textarea"
366
+ inputProps={{ placeholder: 'e.g. Asthma — uses inhaler before PE' }}
367
+ />
368
+ </div>
369
+ );
370
+ }
371
+ export default WithFieldDescriptionExample;
372
+ `.trim(),
373
+ },
374
+ },
375
+ },
376
+ render: () => (
377
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
378
+ <FormField
379
+ id="preferred-name"
380
+ label="Preferred name (optional)"
381
+ fieldDescription="Shown on the parent-facing report card"
382
+ inputProps={{ placeholder: 'e.g. Rose' }}
383
+ />
384
+ <FormField
385
+ id="medical-notes"
386
+ label="Medical conditions"
387
+ fieldDescription="Up to 30 characters. Visible to form tutors only."
388
+ inputType="textarea"
389
+ inputProps={{ placeholder: 'e.g. Asthma — uses inhaler before PE' }}
390
+ />
391
+ </div>
392
+ ),
393
+ },
394
+ [
395
+ 'A field description provides static contextual guidance below the label — it tells the user what',
396
+ 'the field is for or what format to use. It appears above the input and stays visible even when',
397
+ 'an error is shown.',
398
+ '',
399
+ '**Content guidance:** target 20–60 characters. Use it for format hints (`"DD/MM/YYYY"`) or',
400
+ 'visibility notes (`"Shown on the parent-facing report"`). Do not use it for validation messages',
401
+ '— that is what `errorText` is for.',
402
+ '',
403
+ '**Never combine with `helperLinkText` / `helperLinkUrl`.** Per Confluence design spec these are',
404
+ 'mutually exclusive — `fieldDescription` is for static guidance, the helper link is for external',
405
+ 'documentation. Using both creates visual noise and breaks the design intent.',
406
+ ].join('\n'),
407
+ );
408
+
409
+ export const WithHelperLink: Story = withDescription(
410
+ {
411
+ parameters: {
412
+ docs: {
413
+ source: {
414
+ language: 'tsx',
415
+ code: `
416
+ import { FormField } from '@arbor-education/design-system.components';
417
+
418
+ function WithHelperLinkExample() {
419
+ return (
420
+ <FormField
421
+ id="sen-category"
422
+ label="SEN category"
423
+ inputType="selectDropdown"
424
+ inputProps={{
425
+ options: [
426
+ { label: 'Cognition and learning', value: 'cognition-learning' },
427
+ { label: 'Communication and interaction', value: 'communication-interaction' },
428
+ { label: 'Social, emotional and mental health', value: 'semh' },
429
+ { label: 'Sensory and physical needs', value: 'sensory-physical' },
430
+ ],
431
+ placeholder: 'Select a category',
432
+ onSelectionChange: (values) => console.log(values),
433
+ }}
434
+ helperLinkText="View SEN framework"
435
+ helperLinkUrl="https://www.gov.uk/government/publications/send-code-of-practice-0-to-25"
436
+ />
437
+ );
438
+ }
439
+ export default WithHelperLinkExample;
440
+ `.trim(),
441
+ },
442
+ },
443
+ },
444
+ render: () => (
445
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
446
+ <FormField
447
+ id="sen-category"
448
+ label="SEN category"
449
+ inputType="selectDropdown"
450
+ inputProps={{
451
+ options: [
452
+ { label: 'Cognition and learning', value: 'cognition-learning' },
453
+ { label: 'Communication and interaction', value: 'communication-interaction' },
454
+ { label: 'Social, emotional and mental health', value: 'semh' },
455
+ { label: 'Sensory and physical needs', value: 'sensory-physical' },
456
+ ],
457
+ placeholder: 'Select a category',
458
+ onSelectionChange: fn(),
459
+ }}
460
+ helperLinkText="View SEN framework"
461
+ helperLinkUrl="https://www.gov.uk/government/publications/send-code-of-practice-0-to-25"
462
+ />
463
+ </div>
464
+ ),
465
+ },
466
+ [
467
+ 'The helper link renders below the input as an action-led anchor with a `arrow-up-right` icon.',
468
+ 'It connects to external documentation — DfE guidance, Confluence pages, or statutory frameworks.',
469
+ '',
470
+ '**Both props required:** `helperLinkText` and `helperLinkUrl` must both be set. Providing only',
471
+ 'one renders nothing at all.',
472
+ '',
473
+ '**Known limitation:** the anchor has no `target="_blank"` — it does not open in a new tab.',
474
+ 'Confluence design guidance says it should, but the current implementation navigates in the same',
475
+ 'tab. Bear this in mind for links to external documentation.',
476
+ '',
477
+ '**Never combine with `fieldDescription`.** Per Confluence design spec these are mutually exclusive.',
478
+ 'The helper link is for external documentation; `fieldDescription` is for inline static guidance.',
479
+ '',
480
+ '**Always provide `label` when using `helperLinkText`.** The link\'s `aria-label` is built as',
481
+ '`"${label} helper link"`. Omit `label` and screen readers announce `"undefined helper link"`.',
482
+ ].join('\n'),
483
+ );
484
+
485
+ export const WithError: Story = withDescription(
486
+ {
487
+ parameters: {
488
+ docs: {
489
+ source: {
490
+ language: 'tsx',
491
+ code: `
492
+ import { FormField } from '@arbor-education/design-system.components';
493
+
494
+ function WithErrorExample() {
495
+ return (
496
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
497
+ <FormField
498
+ id="date-of-birth"
499
+ label="Date of birth"
500
+ inputType="datePicker"
501
+ inputProps={{ onChange: (date) => console.log(date) }}
502
+ errorText="Enter a date in DD/MM/YYYY format"
503
+ />
504
+ <FormField
505
+ id="class-size"
506
+ label="Class size"
507
+ inputType="number"
508
+ inputProps={{ min: 1, max: 35, placeholder: '30' }}
509
+ errorText="Class size must be between 1 and 35"
510
+ />
511
+ </div>
512
+ );
513
+ }
514
+ export default WithErrorExample;
515
+ `.trim(),
516
+ },
517
+ },
518
+ },
519
+ render: () => (
520
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
521
+ <FormField
522
+ id="date-of-birth"
523
+ label="Date of birth"
524
+ inputType="datePicker"
525
+ inputProps={{ onChange: fn() }}
526
+ errorText="Enter a date in DD/MM/YYYY format"
527
+ />
528
+ <FormField
529
+ id="class-size"
530
+ label="Class size"
531
+ inputType="number"
532
+ inputProps={{ min: 1, max: 35, placeholder: '30' }}
533
+ errorText="Class size must be between 1 and 35"
534
+ />
535
+ </div>
536
+ ),
537
+ },
538
+ [
539
+ 'The error state. Setting `errorText` automatically:',
540
+ '',
541
+ '- Renders a `triangle-alert` icon followed by the error message below the input',
542
+ '- Applies `hasError={true}` to the inner input (which adds `ds-input--error` classes)',
543
+ '- Sets `aria-invalid={true}` on the inner input',
544
+ '- Adds the error span to the input\'s `aria-describedby`',
545
+ '',
546
+ '**Write errors as:** what went wrong + how to fix it. `"Enter a date in DD/MM/YYYY format"` is',
547
+ 'better than `"Invalid date"`. `"Class size must be between 1 and 35"` beats `"Invalid number"`.',
548
+ '',
549
+ '**One error per field.** FormField only accepts a single `errorText` string — render one clear,',
550
+ 'actionable message rather than a list.',
551
+ '',
552
+ '**Do NOT set `hasError` or `aria-invalid` in `inputProps`** — FormField sets these for you.',
553
+ ].join('\n'),
554
+ );
555
+
556
+ export const WithErrorAndFieldDescription: Story = withDescription(
557
+ {
558
+ parameters: {
559
+ docs: {
560
+ source: {
561
+ language: 'tsx',
562
+ code: `
563
+ import { FormField } from '@arbor-education/design-system.components';
564
+
565
+ function WithErrorAndFieldDescriptionExample() {
566
+ return (
567
+ <FormField
568
+ id="student-upn"
569
+ label="Unique Pupil Number (UPN)"
570
+ fieldDescription="13-character code on previous school letter"
571
+ inputProps={{ placeholder: 'e.g. A123456789012' }}
572
+ errorText="Enter a valid 13-character UPN — check the letter from the previous school"
573
+ />
574
+ );
575
+ }
576
+ export default WithErrorAndFieldDescriptionExample;
577
+ `.trim(),
578
+ },
579
+ },
580
+ },
581
+ render: () => (
582
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
583
+ <FormField
584
+ id="student-upn"
585
+ label="Unique Pupil Number (UPN)"
586
+ fieldDescription="13-character code on previous school letter"
587
+ inputProps={{ placeholder: 'e.g. A123456789012' }}
588
+ errorText="Enter a valid 13-character UPN — check the letter from the previous school"
589
+ />
590
+ </div>
591
+ ),
592
+ },
593
+ [
594
+ 'Both `fieldDescription` and `errorText` can coexist. The description stays visible above the',
595
+ 'input as usual; the error appears below. Both are connected to the input via `aria-describedby`',
596
+ 'so screen readers announce both the description context and the error.',
597
+ '',
598
+ 'This is confirmed behaviour from the source: `fieldDescription` is never hidden when an error',
599
+ 'is active. The design rationale is that the description provides context the user still needs',
600
+ 'to fix the error correctly.',
601
+ '',
602
+ 'The `aria-describedby` value will be `"{id}-description {id}-error"` — both IDs concatenated.',
603
+ ].join('\n'),
604
+ );
605
+
606
+ export const WithErrorAndHelperLink: Story = withDescription(
607
+ {
608
+ parameters: {
609
+ docs: {
610
+ source: {
611
+ language: 'tsx',
612
+ code: `
613
+ import { FormField } from '@arbor-education/design-system.components';
614
+
615
+ function WithErrorAndHelperLinkExample() {
616
+ return (
617
+ <FormField
618
+ id="attendance-code"
619
+ label="Attendance code"
620
+ inputProps={{ placeholder: 'e.g. B' }}
621
+ errorText="Enter a valid DfE attendance code (single letter A–Z)"
622
+ helperLinkText="View attendance code reference"
623
+ helperLinkUrl="https://www.gov.uk/government/publications/school-attendance"
624
+ />
625
+ );
626
+ }
627
+ export default WithErrorAndHelperLinkExample;
628
+ `.trim(),
629
+ },
630
+ },
631
+ },
632
+ render: () => (
633
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
634
+ <FormField
635
+ id="attendance-code"
636
+ label="Attendance code"
637
+ inputProps={{ placeholder: 'e.g. B' }}
638
+ errorText="Enter a valid DfE attendance code (single letter A–Z)"
639
+ helperLinkText="View attendance code reference"
640
+ helperLinkUrl="https://www.gov.uk/government/publications/school-attendance"
641
+ />
642
+ </div>
643
+ ),
644
+ },
645
+ [
646
+ '`errorText` and the helper link CAN appear together — this is the one exception where the',
647
+ 'message container renders both elements simultaneously: the error appears first, the helper link',
648
+ 'below it.',
649
+ '',
650
+ 'This is useful when the validation failure is likely to require the user to look up a reference',
651
+ '— for example, an attendance code or a statutory identifier.',
652
+ ].join('\n'),
653
+ );
654
+
655
+ export const Disabled: Story = withDescription(
656
+ {
657
+ parameters: {
658
+ docs: {
659
+ source: {
660
+ language: 'tsx',
661
+ code: `
662
+ import { FormField } from '@arbor-education/design-system.components';
663
+
664
+ function DisabledExample() {
665
+ return (
666
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
667
+ <FormField
668
+ id="admissions-number"
669
+ label="Admissions number"
670
+ fieldDescription="Assigned by your local authority — cannot be edited"
671
+ inputProps={{ disabled: true, value: 'ADM-2024-00412', readOnly: true }}
672
+ />
673
+ <FormField
674
+ id="school-urn"
675
+ label="School URN"
676
+ fieldDescription="Unique Reference Number — set by Ofsted"
677
+ inputProps={{ disabled: true, value: '123456' }}
678
+ />
679
+ </div>
680
+ );
681
+ }
682
+ export default DisabledExample;
683
+ `.trim(),
684
+ },
685
+ },
686
+ },
687
+ render: () => (
688
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
689
+ <FormField
690
+ id="admissions-number"
691
+ label="Admissions number"
692
+ fieldDescription="Assigned by your local authority — cannot be edited"
693
+ inputProps={{ disabled: true, value: 'ADM-2024-00412', readOnly: true }}
694
+ />
695
+ <FormField
696
+ id="school-urn"
697
+ label="School URN"
698
+ fieldDescription="Unique Reference Number — set by Ofsted"
699
+ inputProps={{ disabled: true, value: '123456' }}
700
+ />
701
+ </div>
702
+ ),
703
+ },
704
+ [
705
+ 'Pass `disabled: true` inside `inputProps` to disable the inner input. The label and description',
706
+ 'remain visible for context; the input becomes non-interactive and muted.',
707
+ '',
708
+ '**Disabled vs read-only:** disabled fields are not focusable and are not submitted with form data.',
709
+ 'Read-only fields (`readOnly: true`) ARE focusable and their value IS submitted. Use read-only',
710
+ 'when the user should be able to select and copy the value (e.g. a generated code or reference).',
711
+ '',
712
+ '**Do not show errors on disabled fields** — a user cannot fix a field they cannot edit.',
713
+ ].join('\n'),
714
+ );
715
+
716
+ export const TextArea: Story = withDescription(
717
+ {
718
+ parameters: {
719
+ docs: {
720
+ source: {
721
+ language: 'tsx',
722
+ code: `
723
+ import { FormField } from '@arbor-education/design-system.components';
724
+
725
+ function TextAreaExample() {
726
+ return (
727
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
728
+ <FormField
729
+ id="pastoral-notes"
730
+ label="Pastoral notes"
731
+ inputType="textarea"
732
+ fieldDescription="Visible to form tutor and head of year only"
80
733
  inputProps={{
81
- placeholder: 'Enter your first name',
734
+ placeholder: 'e.g. Supporting bereavement — meeting with school counsellor Tuesdays',
735
+ rows: 3,
82
736
  }}
83
737
  />
84
738
  <FormField
85
- id="email"
86
- label="Email"
739
+ id="medical-conditions"
740
+ label="Medical conditions (optional)"
741
+ inputType="textarea"
87
742
  inputProps={{
88
- placeholder: 'Enter your email',
743
+ placeholder: 'e.g. Type 1 diabetes — checks blood sugar before lunch',
744
+ rows: 3,
89
745
  }}
90
- helperLinkText="More information"
91
- helperLinkUrl="https://www.google.com"
92
746
  />
747
+ </div>
748
+ );
749
+ }
750
+ export default TextAreaExample;
751
+ `.trim(),
752
+ },
753
+ },
754
+ },
755
+ render: () => (
756
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
757
+ <FormField
758
+ id="pastoral-notes"
759
+ label="Pastoral notes"
760
+ inputType="textarea"
761
+ fieldDescription="Visible to form tutor and head of year only"
762
+ inputProps={{
763
+ placeholder: 'e.g. Supporting bereavement — meeting with school counsellor Tuesdays',
764
+ rows: 3,
765
+ }}
766
+ />
767
+ <FormField
768
+ id="medical-conditions"
769
+ label="Medical conditions (optional)"
770
+ inputType="textarea"
771
+ inputProps={{
772
+ placeholder: 'e.g. Type 1 diabetes — checks blood sugar before lunch',
773
+ rows: 3,
774
+ }}
775
+ />
776
+ </div>
777
+ ),
778
+ },
779
+ [
780
+ '`inputType="textarea"` renders a `TextArea` — a multi-line input that grows automatically as',
781
+ 'the user types (`autoSize={true}` is the default). The initial height is controlled by the',
782
+ '`rows` prop in `inputProps`.',
783
+ '',
784
+ '**Use for:** pastoral notes, medical conditions, free-text feedback, long descriptions.',
785
+ '',
786
+ '**Auto-sizing:** the textarea expands vertically as the user types — it does not scroll.',
787
+ 'To opt out, pass `autoSize: false` in `inputProps`.',
788
+ '',
789
+ '**All shared wiring still applies:** `errorText`, `fieldDescription`, and `id` work identically.',
790
+ ].join('\n'),
791
+ );
792
+
793
+ export const NumberInput: Story = withDescription(
794
+ {
795
+ parameters: {
796
+ docs: {
797
+ source: {
798
+ language: 'tsx',
799
+ code: `
800
+ import { FormField } from '@arbor-education/design-system.components';
801
+
802
+ function NumberInputExample() {
803
+ return (
804
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
93
805
  <FormField
94
- id="password"
95
- label="Password"
806
+ id="class-capacity"
807
+ label="Class capacity"
808
+ inputType="number"
809
+ fieldDescription="Maximum number of students in this class"
96
810
  inputProps={{
97
- placeholder: 'Enter your first name',
98
- type: 'password',
811
+ min: 1,
812
+ max: 35,
813
+ step: 1,
814
+ defaultValue: 30,
99
815
  }}
100
816
  />
101
817
  <FormField
102
- id="message"
103
- label="Message"
104
- inputType="textarea"
818
+ id="fsm-percentage"
819
+ label="FSM threshold (%)"
820
+ inputType="number"
821
+ fieldDescription="Free school meals eligibility threshold"
105
822
  inputProps={{
106
- placeholder: 'Enter a lovely message',
823
+ min: 0,
824
+ max: 100,
825
+ step: 5,
826
+ defaultValue: 20,
107
827
  }}
108
828
  />
109
829
  <FormField
110
- id="age"
111
- label="Age"
830
+ id="absence-sessions"
831
+ label="Absence sessions"
112
832
  inputType="number"
833
+ fieldDescription="Number of half-day sessions (no spinner)"
113
834
  inputProps={{
114
- placeholder: 'Enter your age',
835
+ min: 0,
836
+ max: 200,
837
+ disableSpinners: true,
838
+ placeholder: '0',
115
839
  }}
116
840
  />
841
+ </div>
842
+ );
843
+ }
844
+ export default NumberInputExample;
845
+ `.trim(),
846
+ },
847
+ },
848
+ },
849
+ render: () => (
850
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
851
+ <FormField
852
+ id="class-capacity"
853
+ label="Class capacity"
854
+ inputType="number"
855
+ fieldDescription="Maximum number of students in this class"
856
+ inputProps={{
857
+ min: 1,
858
+ max: 35,
859
+ step: 1,
860
+ defaultValue: 30,
861
+ }}
862
+ />
863
+ <FormField
864
+ id="fsm-percentage"
865
+ label="FSM threshold (%)"
866
+ inputType="number"
867
+ fieldDescription="Free school meals eligibility threshold"
868
+ inputProps={{
869
+ min: 0,
870
+ max: 100,
871
+ step: 5,
872
+ defaultValue: 20,
873
+ }}
874
+ />
875
+ <FormField
876
+ id="absence-sessions"
877
+ label="Absence sessions"
878
+ inputType="number"
879
+ fieldDescription="Number of half-day sessions (no spinner)"
880
+ inputProps={{
881
+ min: 0,
882
+ max: 200,
883
+ disableSpinners: true,
884
+ placeholder: '0',
885
+ }}
886
+ />
887
+ </div>
888
+ ),
889
+ },
890
+ [
891
+ '`inputType="number"` renders a `NumberInput` — a text input (`type="text"`, `inputMode="numeric"`)',
892
+ 'with optional ± spinner buttons and decimal-safe arithmetic via `decimal.js`.',
893
+ '',
894
+ '**Do NOT pass `type` in `inputProps`.** `NumberInput` renders as `type="text"` internally to',
895
+ 'avoid browser number input quirks. The component handles formatting and validation itself.',
896
+ '',
897
+ '**`min` / `max` / `step`:** on blur and on spinner click, the value is clamped to the range.',
898
+ 'Pass `step={5}` for coarse increments (e.g. FSM thresholds at 5% steps).',
899
+ '',
900
+ '**`disableSpinners={true}`:** removes the ± buttons for fields where the user should type',
901
+ 'directly rather than increment — large session counts, reference numbers.',
902
+ ].join('\n'),
903
+ );
904
+
905
+ export const TimeInput: Story = withDescription(
906
+ {
907
+ parameters: {
908
+ docs: {
909
+ source: {
910
+ language: 'tsx',
911
+ code: `
912
+ import { FormField } from '@arbor-education/design-system.components';
913
+
914
+ function TimeInputExample() {
915
+ return (
916
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
117
917
  <FormField
118
- id="start-time"
119
- label="Start time"
918
+ id="registration-time"
919
+ label="Registration time"
120
920
  inputType="time"
121
- inputProps={{
122
- 'aria-label': 'Start time',
123
- 'defaultValue': '14:30',
124
- }}
921
+ fieldDescription="Morning registration start time"
922
+ inputProps={{ defaultValue: '08:45' }}
125
923
  />
126
924
  <FormField
127
- id="colour-dropdown"
128
- label="Colour"
129
- inputType="colourPicker"
925
+ id="period-start"
926
+ label="Period start"
927
+ inputType="time"
928
+ fieldDescription="Choose from preset timetable slots"
130
929
  inputProps={{
131
- onChange: fn(),
930
+ options: ['08:00', '08:45', '09:30', '10:15', '11:00', '11:45', '12:30', '13:15', '14:00', '14:45', '15:30'],
931
+ placeholder: 'Select a period',
132
932
  }}
133
933
  />
934
+ </div>
935
+ );
936
+ }
937
+ export default TimeInputExample;
938
+ `.trim(),
939
+ },
940
+ },
941
+ },
942
+ render: () => (
943
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
944
+ <FormField
945
+ id="registration-time"
946
+ label="Registration time"
947
+ inputType="time"
948
+ fieldDescription="Morning registration start time"
949
+ inputProps={{ defaultValue: '08:45' }}
950
+ />
951
+ <FormField
952
+ id="period-start"
953
+ label="Period start"
954
+ inputType="time"
955
+ fieldDescription="Choose from preset timetable slots"
956
+ inputProps={{
957
+ options: ['08:00', '08:45', '09:30', '10:15', '11:00', '11:45', '12:30', '13:15', '14:00', '14:45', '15:30'],
958
+ placeholder: 'Select a period',
959
+ }}
960
+ />
961
+ </div>
962
+ ),
963
+ },
964
+ [
965
+ '`inputType="time"` renders a `TimeInput`, which has two modes:',
966
+ '',
967
+ '**Native mode** (no `options` prop): renders the browser\'s native `<input type="time">` with a',
968
+ 'clock icon button. The value format is `"HH:MM"` or `"HH:MM:SS"`. Use `granularity="second"`',
969
+ 'in `inputProps` to add seconds precision.',
970
+ '',
971
+ '**Combobox mode** (`options` array provided): replaces the native picker with a searchable',
972
+ 'Combobox preset list — ideal for timetabling screens where slots are fixed.',
973
+ 'Pass `options={["08:00", "08:45", ...]}` as an array of `"HH:MM"` strings.',
974
+ '',
975
+ 'Use `onValueChange` in `inputProps` to receive value changes (accepts a string).',
976
+ 'Use `onChange` if you need the native `ChangeEvent<HTMLInputElement>` interface.',
977
+ ].join('\n'),
978
+ );
979
+
980
+ export const WithSelectDropdown: Story = withDescription(
981
+ {
982
+ parameters: {
983
+ docs: {
984
+ source: {
985
+ language: 'tsx',
986
+ code: `
987
+ import { FormField } from '@arbor-education/design-system.components';
988
+
989
+ function WithSelectDropdownExample() {
990
+ return (
991
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
134
992
  <FormField
135
- id="select-dropdown"
136
- label="Select"
993
+ id="year-group"
994
+ label="Year group"
137
995
  inputType="selectDropdown"
138
996
  inputProps={{
139
- options: [{ label: 'Option 1', value: 'option1' }, { label: 'Option 2', value: 'option2' }, { label: 'Option 3', value: 'option3' }],
140
- onSelectionChange: fn(),
997
+ options: [
998
+ { label: 'Year 7', value: 'y7' },
999
+ { label: 'Year 8', value: 'y8' },
1000
+ { label: 'Year 9', value: 'y9' },
1001
+ { label: 'Year 10', value: 'y10' },
1002
+ { label: 'Year 11', value: 'y11' },
1003
+ ],
1004
+ placeholder: 'Select year group',
1005
+ onSelectionChange: (values) => console.log(values),
141
1006
  }}
142
1007
  />
143
1008
  <FormField
144
- id="assignee"
145
- label="Assignee"
146
- inputType="combobox"
1009
+ id="subject-filter"
1010
+ label="Subjects taught (optional)"
1011
+ inputType="selectDropdown"
1012
+ fieldDescription="Select all that apply"
147
1013
  inputProps={{
148
- options: comboboxPeopleOptions,
149
- placeholder: 'Search people...',
150
- onValueChange: fn(),
1014
+ multiple: true,
1015
+ options: [
1016
+ { label: 'Maths', value: 'maths' },
1017
+ { label: 'English', value: 'english' },
1018
+ { label: 'Science', value: 'science' },
1019
+ { label: 'History', value: 'history' },
1020
+ { label: 'Geography', value: 'geography' },
1021
+ { label: 'Art', value: 'art' },
1022
+ { label: 'Music', value: 'music' },
1023
+ { label: 'Physical Education', value: 'pe' },
1024
+ ],
1025
+ placeholder: 'Select subjects',
1026
+ onSelectionChange: (values) => console.log(values),
151
1027
  }}
152
1028
  />
1029
+ </div>
1030
+ );
1031
+ }
1032
+ export default WithSelectDropdownExample;
1033
+ `.trim(),
1034
+ },
1035
+ },
1036
+ },
1037
+ render: () => (
1038
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1039
+ <FormField
1040
+ id="year-group"
1041
+ label="Year group"
1042
+ inputType="selectDropdown"
1043
+ inputProps={{
1044
+ options: [
1045
+ { label: 'Year 7', value: 'y7' },
1046
+ { label: 'Year 8', value: 'y8' },
1047
+ { label: 'Year 9', value: 'y9' },
1048
+ { label: 'Year 10', value: 'y10' },
1049
+ { label: 'Year 11', value: 'y11' },
1050
+ ],
1051
+ placeholder: 'Select year group',
1052
+ onSelectionChange: fn(),
1053
+ }}
1054
+ />
1055
+ <FormField
1056
+ id="subject-filter"
1057
+ label="Subjects taught (optional)"
1058
+ inputType="selectDropdown"
1059
+ fieldDescription="Select all that apply"
1060
+ inputProps={{
1061
+ multiple: true,
1062
+ options: [
1063
+ { label: 'Maths', value: 'maths' },
1064
+ { label: 'English', value: 'english' },
1065
+ { label: 'Science', value: 'science' },
1066
+ { label: 'History', value: 'history' },
1067
+ { label: 'Geography', value: 'geography' },
1068
+ { label: 'Art', value: 'art' },
1069
+ { label: 'Music', value: 'music' },
1070
+ { label: 'Physical Education', value: 'pe' },
1071
+ ],
1072
+ placeholder: 'Select subjects',
1073
+ onSelectionChange: fn(),
1074
+ }}
1075
+ />
1076
+ </div>
1077
+ ),
1078
+ },
1079
+ [
1080
+ '`inputType="selectDropdown"` renders a `SelectDropdown` — a Dropdown-based button trigger with',
1081
+ 'a list of selectable options. Supports single select (default) and multi-select (`multiple={true}`).',
1082
+ '',
1083
+ '**Options shape:** each option is `{ label: string; value: string; group?: string }`. Grouped',
1084
+ 'options render a bold header row above each group.',
1085
+ '',
1086
+ '**Multi-select:** pass `multiple={true}` in `inputProps`. The trigger shows the count of',
1087
+ 'selected values when more than one is chosen.',
1088
+ '',
1089
+ '**Controlled vs uncontrolled:** pass `selectedValues` to control the selection externally;',
1090
+ 'omit it for uncontrolled (internal state). Use `initialSelectedValues` to set a default.',
1091
+ ].join('\n'),
1092
+ );
1093
+
1094
+ export const WithCombobox: Story = withDescription(
1095
+ {
1096
+ parameters: {
1097
+ docs: {
1098
+ source: {
1099
+ language: 'tsx',
1100
+ code: `
1101
+ import { FormField } from '@arbor-education/design-system.components';
1102
+
1103
+ function WithComboboxExample() {
1104
+ const tutorOptions = [
1105
+ { value: 'alice', label: 'Alice Johnson', iconName: 'user' },
1106
+ { value: 'bob', label: 'Bob Smith', iconName: 'user' },
1107
+ { value: 'charlie', label: 'Charlie Brown', iconName: 'user' },
1108
+ { value: 'diana', label: 'Diana Prince', iconName: 'user' },
1109
+ ];
1110
+
1111
+ return (
1112
+ <FormField
1113
+ id="form-tutor"
1114
+ label="Form tutor"
1115
+ inputType="combobox"
1116
+ fieldDescription="Assigned tutor for this form group"
1117
+ inputProps={{
1118
+ options: tutorOptions,
1119
+ placeholder: 'Search by name...',
1120
+ onValueChange: (values) => console.log(values),
1121
+ }}
1122
+ />
1123
+ );
1124
+ }
1125
+ export default WithComboboxExample;
1126
+ `.trim(),
1127
+ },
1128
+ },
1129
+ },
1130
+ render: () => (
1131
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
1132
+ <FormField
1133
+ id="form-tutor"
1134
+ label="Form tutor"
1135
+ inputType="combobox"
1136
+ fieldDescription="Assigned tutor for this form group"
1137
+ inputProps={{
1138
+ options: comboboxPeopleOptions,
1139
+ placeholder: 'Search by name...',
1140
+ onValueChange: fn(),
1141
+ }}
1142
+ />
1143
+ </div>
1144
+ ),
1145
+ },
1146
+ [
1147
+ '`inputType="combobox"` renders a `Combobox` — a searchable, type-ahead selector.',
1148
+ 'The user types to filter the options list; results highlight matching characters.',
1149
+ '',
1150
+ '**Use for:** any "select a person" scenario — form tutor assignment, cover teacher lookup,',
1151
+ 'parent/guardian search. Works with the `comboboxPeopleOptions` mock for stories.',
1152
+ '',
1153
+ '**`onValueChange`** in `inputProps` receives the selected value as `string[]` (Combobox is',
1154
+ 'inherently multi-value even in single-select mode — expect an array of one).',
1155
+ '',
1156
+ '**Options shape:** `{ value: string; label: string; iconName?: IconName; group?: string }`.',
1157
+ 'Provide `iconName="user"` for people pickers to render the person icon.',
1158
+ '',
1159
+ '**Note:** `fieldDescription` is shown here alongside the combobox — this is intentional.',
1160
+ 'Do not add `helperLinkText` when `fieldDescription` is already present.',
1161
+ ].join('\n'),
1162
+ );
1163
+
1164
+ export const WithDatePicker: Story = withDescription(
1165
+ {
1166
+ parameters: {
1167
+ docs: {
1168
+ source: {
1169
+ language: 'tsx',
1170
+ code: `
1171
+ import { FormField } from '@arbor-education/design-system.components';
1172
+
1173
+ function WithDatePickerExample() {
1174
+ return (
1175
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
153
1176
  <FormField
154
1177
  id="date-of-birth"
155
- label="Date of Birth"
1178
+ label="Date of birth"
1179
+ inputType="datePicker"
1180
+ fieldDescription="Format: DD/MM/YYYY"
1181
+ inputProps={{ onChange: (date) => console.log(date) }}
1182
+ />
1183
+ <FormField
1184
+ id="enrolment-date"
1185
+ label="Enrolment date"
156
1186
  inputType="datePicker"
157
- inputProps={{ onChange: fn() }}
1187
+ inputProps={{
1188
+ onChange: (date) => console.log(date),
1189
+ displayFormat: 'default',
1190
+ }}
158
1191
  />
159
1192
  </div>
160
- ),
161
- };
1193
+ );
1194
+ }
1195
+ export default WithDatePickerExample;
1196
+ `.trim(),
1197
+ },
1198
+ },
1199
+ },
1200
+ render: () => (
1201
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1202
+ <FormField
1203
+ id="date-of-birth"
1204
+ label="Date of birth"
1205
+ inputType="datePicker"
1206
+ fieldDescription="Format: DD/MM/YYYY"
1207
+ inputProps={{ onChange: fn() }}
1208
+ />
1209
+ <FormField
1210
+ id="enrolment-date"
1211
+ label="Enrolment date"
1212
+ inputType="datePicker"
1213
+ inputProps={{
1214
+ onChange: fn(),
1215
+ displayFormat: 'default',
1216
+ }}
1217
+ />
1218
+ </div>
1219
+ ),
1220
+ },
1221
+ [
1222
+ '`inputType="datePicker"` renders a `DatePicker` — a text input that opens a calendar popover',
1223
+ 'when clicked. The user can type the date directly or pick from the calendar.',
1224
+ '',
1225
+ '**`onChange`** in `inputProps` receives a `Date | undefined` — not a string. Format it for',
1226
+ 'display or API submission in your application layer.',
1227
+ '',
1228
+ '**`displayFormat`** controls how the date is shown in the text input. Defaults to `"default"`',
1229
+ '(locale-appropriate). Check the DatePicker stories for the full format reference.',
1230
+ '',
1231
+ '**Portalled component:** the calendar popover is rendered via a portal. Do not wrap this story',
1232
+ 'or any component using FormField with `overflow: hidden` or `position: relative` on a tight',
1233
+ 'container — it will clip the calendar.',
1234
+ ].join('\n'),
1235
+ );
1236
+
1237
+ export const OptionalAndRequiredFields: Story = withDescription(
1238
+ {
1239
+ parameters: {
1240
+ docs: {
1241
+ source: {
1242
+ language: 'tsx',
1243
+ code: `
1244
+ import { FormField } from '@arbor-education/design-system.components';
162
1245
 
163
- export const Combobox: Story = {
164
- render: () => (
165
- <div data-surface="base" data-colour-mode="light" className="bg-surface text-on-surface-default" style={{ padding: 32, maxWidth: 420 }}>
1246
+ function OptionalAndRequiredFieldsExample() {
1247
+ return (
1248
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
166
1249
  <FormField
167
- id="form-field-combobox"
168
- label="Assignee"
169
- fieldDescription="Search and select a person."
170
- inputType="combobox"
1250
+ id="student-forename"
1251
+ label="First name"
1252
+ inputProps={{ placeholder: 'e.g. Rose' }}
1253
+ />
1254
+ <FormField
1255
+ id="student-middle-name"
1256
+ label="Middle name (optional)"
1257
+ inputProps={{ placeholder: 'e.g. Marie' }}
1258
+ />
1259
+ <FormField
1260
+ id="student-surname"
1261
+ label="Surname"
1262
+ inputProps={{ placeholder: 'e.g. Nylund' }}
1263
+ />
1264
+ <FormField
1265
+ id="student-preferred-name"
1266
+ label="Preferred name (optional)"
1267
+ fieldDescription="Used in class registers and parent communications"
1268
+ inputProps={{ placeholder: 'e.g. Rosie' }}
1269
+ />
1270
+ <FormField
1271
+ id="student-legal-name"
1272
+ label="Legal name"
1273
+ fieldDescription="As it appears on official documents"
1274
+ inputProps={{ placeholder: 'e.g. Rose Marie Nylund' }}
1275
+ />
1276
+ </div>
1277
+ );
1278
+ }
1279
+ export default OptionalAndRequiredFieldsExample;
1280
+ `.trim(),
1281
+ },
1282
+ },
1283
+ },
1284
+ render: () => (
1285
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1286
+ <FormField
1287
+ id="student-forename"
1288
+ label="First name"
1289
+ inputProps={{ placeholder: 'e.g. Rose' }}
1290
+ />
1291
+ <FormField
1292
+ id="student-middle-name"
1293
+ label="Middle name (optional)"
1294
+ inputProps={{ placeholder: 'e.g. Marie' }}
1295
+ />
1296
+ <FormField
1297
+ id="student-surname"
1298
+ label="Surname"
1299
+ inputProps={{ placeholder: 'e.g. Nylund' }}
1300
+ />
1301
+ <FormField
1302
+ id="student-preferred-name"
1303
+ label="Preferred name (optional)"
1304
+ fieldDescription="Used in class registers and parent communications"
1305
+ inputProps={{ placeholder: 'e.g. Rosie' }}
1306
+ />
1307
+ <FormField
1308
+ id="student-legal-name"
1309
+ label="Legal name"
1310
+ fieldDescription="As it appears on official documents"
1311
+ inputProps={{ placeholder: 'e.g. Rose Marie Nylund' }}
1312
+ />
1313
+ </div>
1314
+ ),
1315
+ },
1316
+ [
1317
+ 'Arbor uses the **label suffix convention** for optional fields — no asterisk, no `required` prop.',
1318
+ 'Append `"(optional)"` directly to the label text: `label="Middle name (optional)"`.',
1319
+ '',
1320
+ 'Fields without this suffix are implicitly required. This is a deliberate Confluence design',
1321
+ 'decision: marking optional fields (the minority) is less visually noisy than marking required',
1322
+ 'fields (the majority) with an asterisk that users learn to ignore anyway.',
1323
+ '',
1324
+ '**Never** use a red asterisk (`*`) required indicator — this is not in the Arbor design system.',
1325
+ '**Never** use placeholder text as a substitute for a label, even for optional fields.',
1326
+ ].join('\n'),
1327
+ );
1328
+
1329
+ export const WithColourPicker: Story = withDescription(
1330
+ {
1331
+ parameters: {
1332
+ docs: {
1333
+ source: {
1334
+ language: 'tsx',
1335
+ code: `
1336
+ import { FormField } from '@arbor-education/design-system.components';
1337
+
1338
+ function WithColourPickerExample() {
1339
+ return (
1340
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1341
+ <FormField
1342
+ id="form-group-colour"
1343
+ label="Form group colour"
1344
+ inputType="colourPicker"
1345
+ fieldDescription="Shown on the timetable and group list"
1346
+ inputProps={{ onChange: (result) => console.log(result.hex) }}
1347
+ />
1348
+ <FormField
1349
+ id="subject-colour"
1350
+ label="Subject colour (optional)"
1351
+ inputType="colourPicker"
171
1352
  inputProps={{
172
- options: comboboxPeopleOptions,
173
- placeholder: 'Search people...',
174
- onValueChange: fn(),
1353
+ value: '#e63946',
1354
+ onChange: (result) => console.log(result.hex),
175
1355
  }}
176
1356
  />
177
1357
  </div>
178
- ),
179
- };
1358
+ );
1359
+ }
1360
+ export default WithColourPickerExample;
1361
+ `.trim(),
1362
+ },
1363
+ },
1364
+ },
1365
+ render: () => (
1366
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1367
+ <FormField
1368
+ id="form-group-colour"
1369
+ label="Form group colour"
1370
+ inputType="colourPicker"
1371
+ fieldDescription="Shown on the timetable and group list"
1372
+ inputProps={{ onChange: fn() }}
1373
+ />
1374
+ <FormField
1375
+ id="subject-colour"
1376
+ label="Subject colour (optional)"
1377
+ inputType="colourPicker"
1378
+ inputProps={{
1379
+ value: '#e63946',
1380
+ onChange: fn(),
1381
+ }}
1382
+ />
1383
+ </div>
1384
+ ),
1385
+ },
1386
+ [
1387
+ '`inputType="colourPicker"` renders a `ColourPickerDropdown` — a button trigger that opens a',
1388
+ 'Sketch colour picker in a Dropdown. The selected colour is previewed in the trigger button.',
1389
+ '',
1390
+ '**`onChange`** in `inputProps` receives a `ColorResult` object from `@uiw/color-convert` —',
1391
+ 'use `.hex` to get the hex string: `onChange={(result) => setColour(result.hex)}`.',
1392
+ '',
1393
+ '**`value`** in `inputProps` accepts a hex string (e.g. `"#3cad51"`). Defaults to Arbor green',
1394
+ '(`"#3cad51"`) if not provided.',
1395
+ '',
1396
+ '**Portalled component:** the colour picker popover is rendered via a portal. Do not wrap',
1397
+ 'with `overflow: hidden` — it will clip the picker.',
1398
+ ].join('\n'),
1399
+ );
180
1400
 
181
- export default meta;
1401
+ export const CompleteStudentForm: Story = withDescription(
1402
+ {
1403
+ parameters: {
1404
+ docs: {
1405
+ source: {
1406
+ language: 'tsx',
1407
+ code: `
1408
+ import { FormField } from '@arbor-education/design-system.components';
1409
+
1410
+ function CompleteStudentFormExample() {
1411
+ const tutorOptions = [
1412
+ { value: 'alice', label: 'Alice Johnson', iconName: 'user' },
1413
+ { value: 'bob', label: 'Bob Smith', iconName: 'user' },
1414
+ { value: 'charlie', label: 'Charlie Brown', iconName: 'user' },
1415
+ ];
1416
+
1417
+ return (
1418
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1419
+ <p style={{ margin: 0, fontWeight: 'var(--font-weight-semi-bold)', color: 'var(--color-grey-900)' }}>
1420
+ New student record
1421
+ </p>
1422
+ <FormField id="student-first-name" label="First name" inputProps={{ placeholder: 'e.g. Dorothy' }} />
1423
+ <FormField id="student-surname" label="Surname" inputProps={{ placeholder: 'e.g. Zbornak' }} />
1424
+ <FormField
1425
+ id="student-preferred-name"
1426
+ label="Preferred name (optional)"
1427
+ fieldDescription="Used in registers and parent communications"
1428
+ inputProps={{ placeholder: 'e.g. Dot' }}
1429
+ />
1430
+ <FormField
1431
+ id="student-dob"
1432
+ label="Date of birth"
1433
+ inputType="datePicker"
1434
+ fieldDescription="Format: DD/MM/YYYY"
1435
+ inputProps={{ onChange: (date) => console.log(date) }}
1436
+ />
1437
+ <FormField
1438
+ id="student-year-group"
1439
+ label="Year group"
1440
+ inputType="selectDropdown"
1441
+ inputProps={{
1442
+ options: [
1443
+ { label: 'Year 7', value: 'y7' },
1444
+ { label: 'Year 8', value: 'y8' },
1445
+ { label: 'Year 9', value: 'y9' },
1446
+ { label: 'Year 10', value: 'y10' },
1447
+ { label: 'Year 11', value: 'y11' },
1448
+ ],
1449
+ placeholder: 'Select year group',
1450
+ onSelectionChange: (values) => console.log(values),
1451
+ }}
1452
+ />
1453
+ <FormField
1454
+ id="student-form-tutor"
1455
+ label="Form tutor"
1456
+ inputType="combobox"
1457
+ inputProps={{
1458
+ options: tutorOptions,
1459
+ placeholder: 'Search by name...',
1460
+ onValueChange: (values) => console.log(values),
1461
+ }}
1462
+ />
1463
+ <FormField
1464
+ id="student-registration-time"
1465
+ label="Registration time"
1466
+ inputType="time"
1467
+ inputProps={{ defaultValue: '08:45' }}
1468
+ />
1469
+ <FormField
1470
+ id="student-class-size"
1471
+ label="Class size"
1472
+ inputType="number"
1473
+ inputProps={{ min: 1, max: 35, defaultValue: 30 }}
1474
+ />
1475
+ <FormField
1476
+ id="student-group-colour"
1477
+ label="Form group colour"
1478
+ inputType="colourPicker"
1479
+ inputProps={{ onChange: (result) => console.log(result.hex) }}
1480
+ />
1481
+ <FormField
1482
+ id="student-pastoral-notes"
1483
+ label="Pastoral notes (optional)"
1484
+ inputType="textarea"
1485
+ fieldDescription="Visible to form tutor and SENCO only"
1486
+ inputProps={{
1487
+ placeholder: 'e.g. Transitioning from primary — mild anxiety in crowded spaces',
1488
+ rows: 3,
1489
+ }}
1490
+ />
1491
+ </div>
1492
+ );
1493
+ }
1494
+ export default CompleteStudentFormExample;
1495
+ `.trim(),
1496
+ },
1497
+ },
1498
+ },
1499
+ render: () => (
1500
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1501
+ <p className="ds-text" style={{ margin: 0, fontWeight: 'var(--font-weight-semi-bold)', color: 'var(--color-grey-900)' }}>
1502
+ New student record
1503
+ </p>
1504
+ <FormField
1505
+ id="student-first-name"
1506
+ label="First name"
1507
+ inputProps={{ placeholder: 'e.g. Dorothy' }}
1508
+ />
1509
+ <FormField
1510
+ id="student-surname"
1511
+ label="Surname"
1512
+ inputProps={{ placeholder: 'e.g. Zbornak' }}
1513
+ />
1514
+ <FormField
1515
+ id="student-preferred-name"
1516
+ label="Preferred name (optional)"
1517
+ fieldDescription="Used in registers and parent communications"
1518
+ inputProps={{ placeholder: 'e.g. Dot' }}
1519
+ />
1520
+ <FormField
1521
+ id="student-dob"
1522
+ label="Date of birth"
1523
+ inputType="datePicker"
1524
+ fieldDescription="Format: DD/MM/YYYY"
1525
+ inputProps={{ onChange: fn() }}
1526
+ />
1527
+ <FormField
1528
+ id="student-year-group"
1529
+ label="Year group"
1530
+ inputType="selectDropdown"
1531
+ inputProps={{
1532
+ options: [
1533
+ { label: 'Year 7', value: 'y7' },
1534
+ { label: 'Year 8', value: 'y8' },
1535
+ { label: 'Year 9', value: 'y9' },
1536
+ { label: 'Year 10', value: 'y10' },
1537
+ { label: 'Year 11', value: 'y11' },
1538
+ ],
1539
+ placeholder: 'Select year group',
1540
+ onSelectionChange: fn(),
1541
+ }}
1542
+ />
1543
+ <FormField
1544
+ id="student-form-tutor"
1545
+ label="Form tutor"
1546
+ inputType="combobox"
1547
+ inputProps={{
1548
+ options: comboboxPeopleOptions,
1549
+ placeholder: 'Search by name...',
1550
+ onValueChange: fn(),
1551
+ }}
1552
+ />
1553
+ <FormField
1554
+ id="student-registration-time"
1555
+ label="Registration time"
1556
+ inputType="time"
1557
+ inputProps={{ defaultValue: '08:45' }}
1558
+ />
1559
+ <FormField
1560
+ id="student-class-size"
1561
+ label="Class size"
1562
+ inputType="number"
1563
+ inputProps={{ min: 1, max: 35, defaultValue: 30 }}
1564
+ />
1565
+ <FormField
1566
+ id="student-group-colour"
1567
+ label="Form group colour"
1568
+ inputType="colourPicker"
1569
+ inputProps={{ onChange: fn() }}
1570
+ />
1571
+ <FormField
1572
+ id="student-pastoral-notes"
1573
+ label="Pastoral notes (optional)"
1574
+ inputType="textarea"
1575
+ fieldDescription="Visible to form tutor and SENCO only"
1576
+ inputProps={{
1577
+ placeholder: 'e.g. Transitioning from primary — mild anxiety in crowded spaces',
1578
+ rows: 3,
1579
+ }}
1580
+ />
1581
+ </div>
1582
+ ),
1583
+ },
1584
+ [
1585
+ 'A realistic school record form using all eight `inputType` values together. This shows how',
1586
+ 'FormField composes into a real data-entry screen — consistent spacing (`var(--spacing-large)`',
1587
+ 'between fields), a shared `maxWidth` container, and school management domain content throughout.',
1588
+ '',
1589
+ 'Notice the conventions in action:',
1590
+ '- Implicitly required fields: no suffix (First name, Surname, Date of birth)',
1591
+ '- Explicitly optional fields: `"(optional)"` suffix (Preferred name, Pastoral notes)',
1592
+ '- `fieldDescription` on fields where format or visibility context helps the user',
1593
+ '- `min` / `max` on the number input to constrain class size',
1594
+ '- `comboboxPeopleOptions` mock for the form tutor Combobox',
1595
+ '',
1596
+ 'This replaces the old `FormExample` story which used `"1rem"` gaps and non-school copy.',
1597
+ ].join('\n'),
1598
+ );