@arbor-education/design-system.components 0.13.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 (142) 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 +6 -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/dot/Dot.stories.d.ts +46 -11
  50. package/dist/components/dot/Dot.stories.d.ts.map +1 -1
  51. package/dist/components/dot/Dot.stories.js +504 -15
  52. package/dist/components/dot/Dot.stories.js.map +1 -1
  53. package/dist/components/dropdown/Dropdown.stories.d.ts +89 -14
  54. package/dist/components/dropdown/Dropdown.stories.d.ts.map +1 -1
  55. package/dist/components/dropdown/Dropdown.stories.js +769 -17
  56. package/dist/components/dropdown/Dropdown.stories.js.map +1 -1
  57. package/dist/components/formField/FormField.stories.d.ts +95 -35
  58. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  59. package/dist/components/formField/FormField.stories.js +1174 -69
  60. package/dist/components/formField/FormField.stories.js.map +1 -1
  61. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts +96 -9
  62. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts.map +1 -1
  63. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js +717 -10
  64. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js.map +1 -1
  65. package/dist/components/formField/inputs/number/NumberInput.stories.d.ts +149 -11
  66. package/dist/components/formField/inputs/number/NumberInput.stories.d.ts.map +1 -1
  67. package/dist/components/formField/inputs/number/NumberInput.stories.js +624 -10
  68. package/dist/components/formField/inputs/number/NumberInput.stories.js.map +1 -1
  69. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.d.ts +74 -1
  70. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.d.ts.map +1 -1
  71. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.js +673 -44
  72. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.js.map +1 -1
  73. package/dist/components/formField/inputs/text/TextInput.stories.d.ts +119 -1
  74. package/dist/components/formField/inputs/text/TextInput.stories.d.ts.map +1 -1
  75. package/dist/components/formField/inputs/text/TextInput.stories.js +549 -10
  76. package/dist/components/formField/inputs/text/TextInput.stories.js.map +1 -1
  77. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +129 -4
  78. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
  79. package/dist/components/formField/inputs/textArea/TextArea.stories.js +577 -3
  80. package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
  81. package/dist/components/heading/Heading.stories.d.ts +449 -50
  82. package/dist/components/heading/Heading.stories.d.ts.map +1 -1
  83. package/dist/components/heading/Heading.stories.js +536 -60
  84. package/dist/components/heading/Heading.stories.js.map +1 -1
  85. package/dist/components/icon/Icon.stories.d.ts +81 -10
  86. package/dist/components/icon/Icon.stories.d.ts.map +1 -1
  87. package/dist/components/icon/Icon.stories.js +979 -8
  88. package/dist/components/icon/Icon.stories.js.map +1 -1
  89. package/dist/components/pill/Pill.stories.d.ts +71 -19
  90. package/dist/components/pill/Pill.stories.d.ts.map +1 -1
  91. package/dist/components/pill/Pill.stories.js +573 -14
  92. package/dist/components/pill/Pill.stories.js.map +1 -1
  93. package/dist/components/progress/Progress.stories.d.ts +75 -298
  94. package/dist/components/progress/Progress.stories.d.ts.map +1 -1
  95. package/dist/components/progress/Progress.stories.js +449 -52
  96. package/dist/components/progress/Progress.stories.js.map +1 -1
  97. package/dist/components/separator/Separator.stories.d.ts +58 -5
  98. package/dist/components/separator/Separator.stories.d.ts.map +1 -1
  99. package/dist/components/separator/Separator.stories.js +443 -4
  100. package/dist/components/separator/Separator.stories.js.map +1 -1
  101. package/dist/components/tag/Tag.stories.d.ts +116 -5
  102. package/dist/components/tag/Tag.stories.d.ts.map +1 -1
  103. package/dist/components/tag/Tag.stories.js +581 -28
  104. package/dist/components/tag/Tag.stories.js.map +1 -1
  105. package/dist/index.css +8 -1
  106. package/dist/index.css.map +1 -1
  107. package/eslint.config.mts +5 -1
  108. package/package.json +3 -3
  109. package/src/components/badge/Badge.stories.tsx +869 -42
  110. package/src/components/banner/Banner.stories.tsx +1081 -63
  111. package/src/components/button/Button.stories.tsx +1394 -99
  112. package/src/components/dot/Dot.stories.tsx +723 -32
  113. package/src/components/dropdown/Dropdown.stories.tsx +1174 -35
  114. package/src/components/formField/FormField.stories.tsx +1522 -105
  115. package/src/components/formField/inputs/checkbox/CheckboxInput.stories.tsx +1020 -15
  116. package/src/components/formField/inputs/number/NumberInput.stories.tsx +908 -15
  117. package/src/components/formField/inputs/radio/RadioButtonInput.stories.tsx +932 -51
  118. package/src/components/formField/inputs/text/TextInput.stories.tsx +773 -13
  119. package/src/components/formField/inputs/textArea/TextArea.stories.tsx +756 -8
  120. package/src/components/heading/Heading.stories.tsx +752 -120
  121. package/src/components/icon/Icon.stories.tsx +1446 -12
  122. package/src/components/pill/Pill.stories.tsx +867 -21
  123. package/src/components/progress/Progress.stories.tsx +625 -58
  124. package/src/components/separator/Separator.stories.tsx +730 -8
  125. package/src/components/separator/separator.scss +12 -3
  126. package/src/components/tag/Tag.stories.tsx +755 -53
  127. package/.claude/agent-memory/blanche-designspert/MEMORY.md +0 -64
  128. package/.claude/agent-memory/dorothy-fact-checker/MEMORY.md +0 -129
  129. package/.claude/agent-memory/rose-storybookspert/MEMORY.md +0 -29
  130. package/.claude/agent-memory/sophia-componentspert/MEMORY.md +0 -14
  131. package/.claude/design-assessment-daily-attendance-2026-04-10.md +0 -566
  132. package/.claude/figma-assessment-7154-58899.md +0 -404
  133. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-11086-97537.md +0 -392
  134. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-551-41974.md +0 -474
  135. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-551-43094.md +0 -462
  136. package/.claude/figma-assessment-fcFK4CGzkz2fVyY3koX8ZE-7154-59061.md +0 -440
  137. package/.claude/migration-report-custom-report-writer-2026-02-19.md +0 -591
  138. /package/{.claude/agent-memory → .agent-memory}/blanche-designspert/token-review-patterns.md +0 -0
  139. /package/{.claude/agent-memory → .agent-memory}/rose-storybookspert/patterns.md +0 -0
  140. /package/{.claude → .gather}/skills/create-page/SKILL.md +0 -0
  141. /package/{.claude → .gather}/skills/map-legacy/SKILL.md +0 -0
  142. /package/{.claude → .gather}/skills/migrate-page/SKILL.md +0 -0
@@ -1,42 +1,802 @@
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 { useRef, useState } from 'react';
2
12
  import { fn } from 'storybook/test';
3
-
13
+ import { FormField } from '../../FormField';
4
14
  import { TextInput } from './TextInput';
5
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 **TextInput** is the single-line text entry control in the Arbor design system.',
23
+ 'It wraps a native `<input>` element with `forwardRef` support, two size variants,',
24
+ 'an error state, and full pass-through of HTML input attributes.',
25
+ ].join('\n');
26
+
27
+ const USAGE_GUIDANCE = [
28
+ '### When to use',
29
+ '',
30
+ '- Free-form single-line text: student names, year group labels, search queries, notes',
31
+ '- Any field where the valid input is a short string the user types directly',
32
+ '- As the underlying input inside a `FormField` with `inputType="text"` (the recommended pattern in real forms)',
33
+ '',
34
+ '---',
35
+ '',
36
+ '### When NOT to use',
37
+ '',
38
+ '| Instead of TextInput... | Use... | Why |',
39
+ '|---|---|---|',
40
+ '| Multi-line text | [`TextArea`](?path=/docs/components-formfield-inputs-textarea--docs) | Expands vertically; better UX for long content |',
41
+ '| Numeric values | [`NumberInput`](?path=/docs/components-formfield-inputs-numeric--docs) | Precision handling, stepper UI, better browser chrome |',
42
+ '| Time values | [`TimeInput`](?path=/docs/components-formfield-inputs-timeinput--docs) | Granularity controls, clock UI, better validation |',
43
+ '| Date selection | `DatePicker` | Calendar UI; avoids inconsistent native date picker chrome |',
44
+ '| Search / filter | [`SearchBar`](?path=/docs/components-searchbar--docs) | Search semantics, clear button, correct ARIA role |',
45
+ '| Selecting from a list | `SelectDropdown` or `Combobox` | Constrained selection; better keyboard and screen reader UX |',
46
+ '| Inline editable text | [`EditableText`](?path=/docs/components-editabletext--docs) | Uses TextInput internally; adds toggle-to-edit affordance |',
47
+ '',
48
+ '---',
49
+ '',
50
+ '### Design guidance',
51
+ '',
52
+ '- **Width is always 100%** — the input always fills its container. Control width by sizing the wrapper, not the input.',
53
+ '- **Stack sizes vertically** when comparing M and S — the 0.25rem height difference is subtle and easier to perceive when stacked.',
54
+ '- **Use FormField in real forms** — `FormField` automatically wires `hasError`, `aria-invalid`, and `aria-describedby`.',
55
+ ' On a bare `TextInput`, you are responsible for those attributes yourself.',
56
+ '- **hasError + disabled is an anti-pattern** — a user cannot correct an error on a field they cannot edit.',
57
+ ' Never show this combination.',
58
+ ].join('\n');
59
+
60
+ const DEVELOPER_NOTES = [
61
+ '### Critical gotchas',
62
+ '',
63
+ '#### 1. `aria-invalid` is NOT automatic on bare TextInput',
64
+ '`hasError={true}` only adds the visual red border class (`ds-input--error`).',
65
+ 'It carries no ARIA semantics. When using TextInput outside FormField,',
66
+ 'you must also pass `aria-invalid="true"` so assistive technologies announce the error state.',
67
+ 'FormField handles this automatically when `errorText` is present.',
68
+ '',
69
+ '```tsx',
70
+ '// BAD — visually red, but screen reader does not know there is an error',
71
+ '<TextInput hasError />',
72
+ '',
73
+ '// GOOD — both the visual class and the ARIA attribute are set',
74
+ '<TextInput hasError aria-invalid="true" aria-describedby="my-field-error" />',
75
+ '<span id="my-field-error">Enter a valid email address</span>',
76
+ '```',
77
+ '',
78
+ '#### 2. `id` is required for label association',
79
+ 'Without an `id`, a `<label>` element cannot use `htmlFor` to associate with the input.',
80
+ 'Outside FormField, wiring `id` and `htmlFor` is your responsibility.',
81
+ '`FormField` passes `id` through automatically.',
82
+ '',
83
+ '#### 3. `type="number"` and `type="time"` — use purpose-built inputs instead',
84
+ 'TextInput passes `type` through to the native `<input>`, but `NumberInput` and `TimeInput`',
85
+ 'provide better experiences: precision handling, stepper UI, granularity controls.',
86
+ 'Only use `type="number"` on bare TextInput as a last resort.',
87
+ '',
88
+ '#### 4. Width is always 100%',
89
+ 'The component always fills its container. Size the wrapper, not the input.',
90
+ 'Never set a `width` or `max-width` directly on the TextInput element.',
91
+ '',
92
+ '#### 5. `hasError + disabled` is an anti-pattern',
93
+ 'Visually ambiguous and semantically incorrect — a user cannot correct an error on a field',
94
+ 'they cannot edit. Do not combine these two props.',
95
+ '',
96
+ '---',
97
+ '',
98
+ '### Accessibility',
99
+ '',
100
+ '- **Label association** — always wire `id` on the input and `htmlFor` on the label.',
101
+ ' `FormField` does this automatically; bare TextInput consumers must do it manually.',
102
+ '- **Error state** — pair `hasError={true}` with `aria-invalid="true"` and',
103
+ ' `aria-describedby` pointing to the error message element.',
104
+ ' `FormField` handles all of this automatically when `errorText` is present.',
105
+ '- **Description text** — use `aria-describedby` pointing to any helper text element.',
106
+ ' `FormField` wires this via `${id}-description` automatically.',
107
+ '- **disabled vs readOnly** — `disabled` removes the field from tab order entirely.',
108
+ ' Use `readOnly` instead when the value must remain accessible and copyable,',
109
+ ' or when a screen reader user needs to reach the field. readOnly fields ARE submitted',
110
+ ' in forms; disabled fields are NOT.',
111
+ '- **Focus ring** — 3px outline using `--focus-border` and `--color-brand-500`.',
112
+ ' Do not suppress focus rings — they are the primary keyboard navigation cue.',
113
+ '- **Placeholder is not a label** — never use placeholder as the only visible label.',
114
+ ' It disappears on input and has poor colour contrast by default.',
115
+ '',
116
+ '---',
117
+ '',
118
+ '### TypeScript types',
119
+ '',
120
+ '```ts',
121
+ "import { TextInput } from '@arbor-education/design-system.components';",
122
+ '',
123
+ 'function MyField(props: TextInput.Props) { ... }',
124
+ '```',
125
+ '',
126
+ '| Type | Description |',
127
+ '|---|---|',
128
+ '| `TextInput.Props` | Full props interface — `size`, `hasError`, plus all `InputHTMLAttributes<HTMLInputElement>` (minus the HTML `size` attribute) |',
129
+ ].join('\n');
130
+
131
+ const RELATED_COMPONENTS = [
132
+ '## Related components',
133
+ '',
134
+ '[FormField](?path=/docs/components-formfield--docs)',
135
+ '· [TextArea](?path=/docs/components-formfield-inputs-textarea--docs)',
136
+ '· [NumberInput](?path=/docs/components-formfield-inputs-numeric--docs)',
137
+ '· [TimeInput](?path=/docs/components-formfield-inputs-timeinput--docs)',
138
+ '· [SearchBar](?path=/docs/components-searchbar--docs)',
139
+ '· [EditableText](?path=/docs/components-editabletext--docs)',
140
+ ].join('\n');
141
+
142
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
143
+
144
+ function TextInputDocsPage() {
145
+ return (
146
+ <>
147
+ <Title />
148
+ <Subtitle />
149
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
150
+ <DocHeading>Interactive example</DocHeading>
151
+ <Markdown>{PROPS_INTRO}</Markdown>
152
+ <DocPrimary />
153
+ <Controls />
154
+ <DocHeading>Usage guidance</DocHeading>
155
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
156
+ <DocHeading>Developer notes</DocHeading>
157
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
158
+ <DocHeading>Examples</DocHeading>
159
+ <Stories title="" />
160
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
161
+ </>
162
+ );
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Meta
167
+ // ---------------------------------------------------------------------------
168
+
6
169
  const meta = {
7
170
  title: 'Components/FormField/Inputs/TextInput',
8
171
  component: TextInput,
9
172
  parameters: {
10
173
  layout: 'centered',
174
+ docs: {
175
+ page: TextInputDocsPage,
176
+ },
11
177
  },
12
178
  tags: ['autodocs'],
13
179
  args: {
14
180
  onChange: fn(),
181
+ onBlur: fn(),
15
182
  },
16
183
  argTypes: {
17
- size: {
184
+ 'size': {
18
185
  control: 'select',
19
186
  options: ['M', 'S'],
20
- description: 'Input size',
187
+ description: [
188
+ 'Controls the height of the input via the `ds-input--M` / `ds-input--S` modifier class.',
189
+ '`M` resolves to `--form-field-text-medium-height` (2.25rem / 36px) — the default for most form contexts.',
190
+ '`S` resolves to `--form-field-text-small-height` (2rem / 32px) — use in dense toolbars or compact table filters.',
191
+ 'Note: the HTML `size` attribute is intentionally omitted from this component.',
192
+ 'Width is always 100% of the parent container regardless of this prop.',
193
+ ].join(' '),
194
+ table: {
195
+ type: { summary: "'M' | 'S'" },
196
+ defaultValue: { summary: "'M'" },
197
+ },
198
+ },
199
+ 'hasError': {
200
+ control: 'boolean',
201
+ description: [
202
+ 'Applies the `ds-input--error` class, which sets a red border.',
203
+ 'When using TextInput inside `FormField`, this prop is set automatically when `errorText` is present',
204
+ '— do NOT pass it manually in `inputProps`.',
205
+ 'When using bare TextInput, also pass `aria-invalid="true"` — `hasError` is purely visual',
206
+ 'and carries no ARIA semantics on its own.',
207
+ ].join(' '),
208
+ table: {
209
+ type: { summary: 'boolean' },
210
+ defaultValue: { summary: 'false' },
211
+ },
21
212
  },
22
- disabled: {
213
+ 'disabled': {
23
214
  control: 'boolean',
24
- description: 'Disable the input',
215
+ description: [
216
+ 'Native HTML disabled attribute. Removes the field from tab order entirely.',
217
+ 'Prevents all interaction and excludes the value from form submission.',
218
+ 'Use `readOnly` instead when the value must remain accessible, copyable, or submitted.',
219
+ ].join(' '),
220
+ table: {
221
+ type: { summary: 'boolean' },
222
+ defaultValue: { summary: 'false' },
223
+ },
224
+ },
225
+ 'placeholder': {
226
+ control: 'text',
227
+ description: [
228
+ 'Hint text displayed when the field is empty.',
229
+ 'Never use placeholder as the only visible label — it disappears on input',
230
+ 'and has poor colour contrast by default. Always pair with a visible `<label>` or `aria-label`.',
231
+ ].join(' '),
232
+ table: {
233
+ type: { summary: 'string' },
234
+ },
25
235
  },
26
- placeholder: {
236
+ 'type': {
27
237
  control: 'text',
28
- description: 'Input placeholder text',
238
+ description: [
239
+ 'HTML input type attribute, passed through to the native `<input>` element.',
240
+ 'Valid values for single-line text scenarios: `"text"` (default), `"email"`, `"password"`,',
241
+ '`"tel"`, `"url"`, `"search"`.',
242
+ 'Avoid `"number"` and `"time"` — use `NumberInput` and `TimeInput` instead for better UX.',
243
+ ].join(' '),
244
+ table: {
245
+ type: { summary: 'string' },
246
+ defaultValue: { summary: "'text'" },
247
+ },
248
+ },
249
+ 'readOnly': {
250
+ control: 'boolean',
251
+ description: [
252
+ 'Makes the field focusable and selectable but not editable.',
253
+ 'The value IS included in form submission (unlike `disabled`).',
254
+ 'The field remains in tab order — use this instead of `disabled` when the value',
255
+ 'must be accessible, readable, or copyable by the user.',
256
+ ].join(' '),
257
+ table: {
258
+ type: { summary: 'boolean' },
259
+ },
260
+ },
261
+ 'value': {
262
+ control: 'text',
263
+ description: [
264
+ 'Controlled value — pair with `onChange` to manage state externally.',
265
+ 'When using controlled mode, you must handle `onChange` otherwise the input will appear frozen.',
266
+ 'For uncontrolled usage, use `defaultValue` instead.',
267
+ ].join(' '),
268
+ table: {
269
+ type: { summary: 'string' },
270
+ },
271
+ },
272
+ 'onChange': {
273
+ description: [
274
+ 'Change event handler. Fires on every keystroke.',
275
+ 'Required when using controlled `value` — otherwise the input will not update.',
276
+ ].join(' '),
277
+ table: {
278
+ type: { summary: 'ChangeEventHandler<HTMLInputElement>' },
279
+ },
280
+ },
281
+ 'onBlur': {
282
+ description: [
283
+ 'Blur event handler. Fires when the field loses focus.',
284
+ 'The canonical trigger for field-level validation — check the value here and set error state if invalid.',
285
+ ].join(' '),
286
+ table: {
287
+ type: { summary: 'FocusEventHandler<HTMLInputElement>' },
288
+ },
289
+ },
290
+ 'maxLength': {
291
+ control: 'number',
292
+ description: [
293
+ 'Maximum number of characters allowed, enforced natively by the browser.',
294
+ 'Useful for fields with a hard character limit — student reference numbers, short codes.',
295
+ 'Consider pairing with a visible character count for longer limits.',
296
+ ].join(' '),
297
+ table: {
298
+ type: { summary: 'number' },
299
+ },
300
+ },
301
+ 'id': {
302
+ control: 'text',
303
+ description: [
304
+ 'HTML `id` attribute — required for label association via `htmlFor` when used outside `FormField`.',
305
+ '`FormField` passes `id` through automatically. On a bare TextInput, always set this',
306
+ 'when a visible label is present so screen readers can associate the label with the field.',
307
+ ].join(' '),
308
+ table: {
309
+ type: { summary: 'string' },
310
+ },
311
+ },
312
+ 'aria-label': {
313
+ control: 'text',
314
+ description: [
315
+ 'Accessible label for contexts where a visible label is not possible',
316
+ '(e.g. an inline filter input in a data grid header).',
317
+ 'If a visible label exists, prefer `id` + `htmlFor` association instead.',
318
+ ].join(' '),
319
+ table: {
320
+ type: { summary: 'string' },
321
+ },
29
322
  },
30
323
  },
31
324
  } satisfies Meta<typeof TextInput>;
32
325
 
33
326
  export default meta;
34
- type Story = StoryObj<typeof meta>;
327
+ type Story = StoryObj<typeof TextInput>;
35
328
 
36
- // Default input
37
- export const Default: Story = {
38
- args: {
39
- placeholder: 'Enter text...',
40
- size: 'M',
329
+ // ---------------------------------------------------------------------------
330
+ // Helper: attach a per-story description to docs
331
+ // ---------------------------------------------------------------------------
332
+ const withDescription = (story: Story, description: string): Story => ({
333
+ ...story,
334
+ parameters: {
335
+ ...story.parameters,
336
+ docs: { ...story.parameters?.docs, description: { story: description } },
41
337
  },
338
+ });
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Stateful template components
342
+ // Named components avoid hooks-in-callbacks lint issues (react-hooks plugin
343
+ // is NOT configured in this project — do not add eslint-disable comments).
344
+ // ---------------------------------------------------------------------------
345
+
346
+ const ControlledWithValidationTemplate = () => {
347
+ const [value, setValue] = useState('');
348
+ const [hasError, setHasError] = useState(false);
349
+
350
+ const handleBlur = () => {
351
+ setHasError(value.length > 0 && !value.includes('@'));
352
+ };
353
+
354
+ return (
355
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
356
+ <label htmlFor="parent-email-controlled" className="ds-text">
357
+ Parent email address
358
+ </label>
359
+ <TextInput
360
+ id="parent-email-controlled"
361
+ type="email"
362
+ placeholder="jane.doe@example.com"
363
+ value={value}
364
+ hasError={hasError}
365
+ aria-invalid={hasError}
366
+ aria-describedby={hasError ? 'parent-email-error' : undefined}
367
+ onChange={(e) => {
368
+ setValue(e.target.value);
369
+ if (hasError && e.target.value.includes('@')) {
370
+ setHasError(false);
371
+ }
372
+ }}
373
+ onBlur={handleBlur}
374
+ />
375
+ {hasError && (
376
+ <span id="parent-email-error" className="ds-text" style={{ color: 'var(--color-semantic-destructive-600)' }}>
377
+ Enter a valid email address
378
+ </span>
379
+ )}
380
+ {!hasError && value.includes('@') && (
381
+ <span className="ds-text" style={{ color: 'var(--color-semantic-success-600)' }}>
382
+ Email looks valid
383
+ </span>
384
+ )}
385
+ </div>
386
+ );
42
387
  };
388
+
389
+ const WithForwardRefTemplate = () => {
390
+ const inputRef = useRef<HTMLInputElement>(null);
391
+
392
+ return (
393
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
394
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)' }}>
395
+ Click the button below to programmatically focus the text input.
396
+ </p>
397
+ <TextInput
398
+ ref={inputRef}
399
+ id="student-name-ref"
400
+ placeholder="Enter student full name"
401
+ aria-label="Student full name"
402
+ />
403
+ <button
404
+ type="button"
405
+ className="ds-button ds-button--secondary ds-button--M"
406
+ onClick={() => inputRef.current?.focus()}
407
+ >
408
+ Focus input
409
+ </button>
410
+ </div>
411
+ );
412
+ };
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Stories
416
+ // ---------------------------------------------------------------------------
417
+
418
+ export const Default: Story = withDescription(
419
+ {
420
+ args: {
421
+ size: 'M',
422
+ placeholder: 'Enter student full name',
423
+ disabled: false,
424
+ hasError: false,
425
+ },
426
+ render: args => (
427
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
428
+ <TextInput {...args} />
429
+ </div>
430
+ ),
431
+ },
432
+ 'The interactive canvas story — every prop is wired to the **Controls** panel. Use the controls to explore sizes, error state, disabled, placeholder, and other HTML input attributes.',
433
+ );
434
+
435
+ export const Sizes: Story = withDescription(
436
+ {
437
+ parameters: {
438
+ docs: {
439
+ source: {
440
+ language: 'tsx',
441
+ code: `
442
+ import { TextInput } from '@arbor-education/design-system.components';
443
+
444
+ function SizesExample() {
445
+ return (
446
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
447
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
448
+ <label htmlFor="size-m">Medium (M) — 2.25rem</label>
449
+ <TextInput id="size-m" size="M" placeholder="Year group label or student name" />
450
+ </div>
451
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
452
+ <label htmlFor="size-s">Small (S) — 2rem</label>
453
+ <TextInput id="size-s" size="S" placeholder="Year group label or student name" />
454
+ </div>
455
+ </div>
456
+ );
457
+ }
458
+ export default SizesExample;
459
+ `.trim(),
460
+ },
461
+ },
462
+ },
463
+ render: () => (
464
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
465
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
466
+ <label htmlFor="size-m" className="ds-text">
467
+ Medium (M) — 2.25rem via --form-field-text-medium-height
468
+ </label>
469
+ <TextInput
470
+ id="size-m"
471
+ size="M"
472
+ placeholder="Year group label or student name"
473
+ />
474
+ </div>
475
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
476
+ <label htmlFor="size-s" className="ds-text">
477
+ Small (S) — 2rem via --form-field-text-small-height
478
+ </label>
479
+ <TextInput
480
+ id="size-s"
481
+ size="S"
482
+ placeholder="Year group label or student name"
483
+ />
484
+ </div>
485
+ </div>
486
+ ),
487
+ },
488
+ 'Both sizes stacked for comparison. `M` is the default for standard form layouts. `S` is for dense toolbars or compact table filters where the medium height would feel too tall. Heights are entirely token-driven — never hardcode a height on a TextInput wrapper.',
489
+ );
490
+
491
+ export const States: Story = withDescription(
492
+ {
493
+ parameters: {
494
+ docs: {
495
+ source: {
496
+ language: 'tsx',
497
+ code: `
498
+ import { TextInput } from '@arbor-education/design-system.components';
499
+
500
+ function StatesExample() {
501
+ return (
502
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
503
+ {/* Default — empty */}
504
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
505
+ <label htmlFor="state-default">Default — empty</label>
506
+ <TextInput id="state-default" placeholder="Enter student full name" />
507
+ </div>
508
+ {/* Error — always pair hasError with aria-invalid + aria-describedby on bare TextInput */}
509
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
510
+ <label htmlFor="state-error">Error — invalid input</label>
511
+ <TextInput
512
+ id="state-error"
513
+ hasError
514
+ aria-invalid="true"
515
+ aria-describedby="state-error-msg"
516
+ defaultValue="not-an-email"
517
+ type="email"
518
+ />
519
+ <span id="state-error-msg" style={{ color: 'var(--color-semantic-destructive-600)' }}>
520
+ Enter a valid email address
521
+ </span>
522
+ </div>
523
+ {/* Disabled */}
524
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
525
+ <label htmlFor="state-disabled">Disabled — not interactive</label>
526
+ <TextInput id="state-disabled" disabled placeholder="Field is disabled" />
527
+ </div>
528
+ </div>
529
+ );
530
+ }
531
+ export default StatesExample;
532
+ `.trim(),
533
+ },
534
+ },
535
+ },
536
+ render: () => (
537
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
538
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
539
+ <label htmlFor="state-default" className="ds-text">Default — empty</label>
540
+ <TextInput id="state-default" placeholder="Enter student full name" />
541
+ </div>
542
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
543
+ <label htmlFor="state-filled" className="ds-text">Filled — value present</label>
544
+ <TextInput id="state-filled" defaultValue="Amara Osei-Bonsu" />
545
+ </div>
546
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
547
+ <label htmlFor="state-error" className="ds-text">Error — invalid input</label>
548
+ <TextInput
549
+ id="state-error"
550
+ hasError
551
+ aria-invalid="true"
552
+ aria-describedby="state-error-msg"
553
+ defaultValue="not-an-email"
554
+ type="email"
555
+ />
556
+ <span id="state-error-msg" className="ds-text" style={{ color: 'var(--color-semantic-destructive-600)' }}>
557
+ Enter a valid email address
558
+ </span>
559
+ </div>
560
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
561
+ <label htmlFor="state-disabled" className="ds-text" style={{ color: 'var(--color-grey-400)' }}>
562
+ Disabled — not interactive
563
+ </label>
564
+ <TextInput
565
+ id="state-disabled"
566
+ disabled
567
+ placeholder="Field is disabled"
568
+ />
569
+ </div>
570
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
571
+ <label htmlFor="state-readonly" className="ds-text">Read only — value visible, not editable</label>
572
+ <TextInput
573
+ id="state-readonly"
574
+ readOnly
575
+ defaultValue="Year 9 — Elm House"
576
+ />
577
+ </div>
578
+ </div>
579
+ ),
580
+ },
581
+ 'All five states in a single view: default (empty), filled (value present), error (with `hasError` + `aria-invalid` + an error message), disabled (removed from tab order; value not submitted), and readOnly (focusable, selectable, submittable — but not editable). Note: the error state on a bare TextInput always requires `aria-invalid="true"` set manually — `FormField` handles this automatically.',
582
+ );
583
+
584
+ export const WithFormField: Story = withDescription(
585
+ {
586
+ parameters: {
587
+ docs: {
588
+ source: {
589
+ language: 'tsx',
590
+ code: `
591
+ import { FormField } from '@arbor-education/design-system.components';
592
+
593
+ function WithFormFieldExample() {
594
+ return (
595
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
596
+ <FormField
597
+ inputType="text"
598
+ label="Student name"
599
+ id="student-name"
600
+ inputProps={{ placeholder: 'Enter student full name' }}
601
+ />
602
+ <FormField
603
+ inputType="text"
604
+ label="Parent email"
605
+ id="parent-email"
606
+ errorText="Enter a valid email address"
607
+ inputProps={{ placeholder: 'jane.doe@example.com', type: 'email' }}
608
+ />
609
+ </div>
610
+ );
611
+ }
612
+ export default WithFormFieldExample;
613
+ `.trim(),
614
+ },
615
+ },
616
+ },
617
+ render: () => (
618
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
619
+ <FormField
620
+ inputType="text"
621
+ label="Student name"
622
+ id="student-name"
623
+ inputProps={{ placeholder: 'Enter student full name' }}
624
+ />
625
+ <FormField
626
+ inputType="text"
627
+ label="Parent email"
628
+ id="parent-email"
629
+ errorText="Enter a valid email address"
630
+ inputProps={{ placeholder: 'jane.doe@example.com', type: 'email' }}
631
+ />
632
+ </div>
633
+ ),
634
+ },
635
+ [
636
+ '**Always use `FormField` in real forms.** `FormField` with `inputType="text"` wraps TextInput and',
637
+ 'automatically wires `hasError`, `aria-invalid`, `aria-describedby`, and the label `htmlFor` association.',
638
+ 'The second example shows how `errorText` automatically sets the error state — do NOT pass',
639
+ '`hasError` or `aria-invalid` manually in `inputProps` when using FormField.',
640
+ ].join(' '),
641
+ );
642
+
643
+ export const InputTypes: Story = withDescription(
644
+ {
645
+ parameters: {
646
+ docs: {
647
+ source: {
648
+ language: 'tsx',
649
+ code: `
650
+ import { TextInput } from '@arbor-education/design-system.components';
651
+
652
+ function InputTypesExample() {
653
+ return (
654
+ <div style={{ maxWidth: '28rem', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
655
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
656
+ <label htmlFor="type-text">type="text" — Student name</label>
657
+ <TextInput id="type-text" type="text" placeholder="Amara Osei-Bonsu" />
658
+ </div>
659
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
660
+ <label htmlFor="type-email">type="email" — Parent email</label>
661
+ <TextInput id="type-email" type="email" placeholder="jane.doe@example.com" />
662
+ </div>
663
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
664
+ <label htmlFor="type-password">type="password" — Set password</label>
665
+ <TextInput id="type-password" type="password" placeholder="Min. 8 characters" />
666
+ </div>
667
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
668
+ <label htmlFor="type-tel">type="tel" — Parent phone number</label>
669
+ <TextInput id="type-tel" type="tel" placeholder="07700 900 123" />
670
+ </div>
671
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
672
+ <label htmlFor="type-url">type="url" — School website</label>
673
+ <TextInput id="type-url" type="url" placeholder="https://example-school.ac.uk" />
674
+ </div>
675
+ </div>
676
+ );
677
+ }
678
+ export default InputTypesExample;
679
+ `.trim(),
680
+ },
681
+ },
682
+ },
683
+ render: () => (
684
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
685
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
686
+ <label htmlFor="type-text" className="ds-text">type="text" — Student name</label>
687
+ <TextInput id="type-text" type="text" placeholder="Amara Osei-Bonsu" />
688
+ </div>
689
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
690
+ <label htmlFor="type-email" className="ds-text">type="email" — Parent email</label>
691
+ <TextInput id="type-email" type="email" placeholder="jane.doe@example.com" />
692
+ </div>
693
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
694
+ <label htmlFor="type-password" className="ds-text">type="password" — Set password</label>
695
+ <TextInput id="type-password" type="password" placeholder="Min. 8 characters" />
696
+ </div>
697
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
698
+ <label htmlFor="type-tel" className="ds-text">type="tel" — Parent phone number</label>
699
+ <TextInput id="type-tel" type="tel" placeholder="07700 900 123" />
700
+ </div>
701
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
702
+ <label htmlFor="type-url" className="ds-text">type="url" — School website</label>
703
+ <TextInput id="type-url" type="url" placeholder="https://example-school.ac.uk" />
704
+ </div>
705
+ </div>
706
+ ),
707
+ },
708
+ 'TextInput passes `type` directly to the native `<input>`. The visual appearance is identical across all types — the difference is in browser behaviour: `email` validates address format, `password` masks characters, `tel` opens a numeric keyboard on mobile, `url` validates URL format. Avoid `type="number"` and `type="time"` — use `NumberInput` and `TimeInput` for those.',
709
+ );
710
+
711
+ export const ControlledWithValidation: Story = withDescription(
712
+ {
713
+ render: () => <ControlledWithValidationTemplate />,
714
+ parameters: {
715
+ docs: {
716
+ source: {
717
+ language: 'tsx',
718
+ code: `
719
+ import { useState } from 'react';
720
+ import { TextInput } from '@arbor-education/design-system.components';
721
+
722
+ function EmailValidationExample() {
723
+ const [value, setValue] = useState('');
724
+ const [hasError, setHasError] = useState(false);
725
+
726
+ const handleBlur = () => {
727
+ setHasError(value.length > 0 && !value.includes('@'));
728
+ };
729
+
730
+ return (
731
+ <div>
732
+ <label htmlFor="parent-email">Parent email address</label>
733
+ <TextInput
734
+ id="parent-email"
735
+ type="email"
736
+ placeholder="jane.doe@example.com"
737
+ value={value}
738
+ hasError={hasError}
739
+ aria-invalid={hasError}
740
+ aria-describedby={hasError ? 'parent-email-error' : undefined}
741
+ onChange={(e) => {
742
+ setValue(e.target.value);
743
+ if (hasError && e.target.value.includes('@')) setHasError(false);
744
+ }}
745
+ onBlur={handleBlur}
746
+ />
747
+ {hasError && (
748
+ <span id="parent-email-error">Enter a valid email address</span>
749
+ )}
750
+ </div>
751
+ );
752
+ }
753
+ `.trim(),
754
+ },
755
+ },
756
+ },
757
+ },
758
+ [
759
+ 'A fully controlled email field with validation-on-blur. State is managed externally:',
760
+ '`value` and `onChange` track the input; `onBlur` checks for `"@"` and sets `hasError`.',
761
+ 'When `hasError` is true, the story also sets `aria-invalid="true"` and',
762
+ '`aria-describedby` pointing to the error message span — this is the correct bare-TextInput',
763
+ 'pattern (FormField handles this wiring automatically).',
764
+ 'The error clears as soon as the user types an `"@"` character, giving immediate positive feedback.',
765
+ ].join(' '),
766
+ );
767
+
768
+ export const WithForwardRef: Story = withDescription(
769
+ {
770
+ render: () => <WithForwardRefTemplate />,
771
+ parameters: {
772
+ docs: {
773
+ source: {
774
+ language: 'tsx',
775
+ code: `
776
+ import { useRef } from 'react';
777
+ import { TextInput } from '@arbor-education/design-system.components';
778
+
779
+ function FocusExample() {
780
+ const inputRef = useRef<HTMLInputElement>(null);
781
+
782
+ return (
783
+ <div>
784
+ <TextInput
785
+ ref={inputRef}
786
+ id="student-name"
787
+ placeholder="Enter student full name"
788
+ aria-label="Student full name"
789
+ />
790
+ <button type="button" onClick={() => inputRef.current?.focus()}>
791
+ Focus input
792
+ </button>
793
+ </div>
794
+ );
795
+ }
796
+ `.trim(),
797
+ },
798
+ },
799
+ },
800
+ },
801
+ '`TextInput` uses `forwardRef` — it exposes the underlying `<input>` DOM node via `ref`. This story demonstrates programmatic focus: clicking the button calls `inputRef.current?.focus()`. Common real-world uses: restoring focus after an inline edit is saved, advancing focus in a multi-step form, or auto-focusing a search field when a panel opens.',
802
+ );