@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,18 +1,766 @@
1
- import type { Meta } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import {
3
+ Controls,
4
+ Heading as DocHeading,
5
+ Markdown,
6
+ Primary as DocPrimary,
7
+ Stories,
8
+ Subtitle,
9
+ Title,
10
+ } from '@storybook/addon-docs/blocks';
11
+ import { useState } from 'react';
2
12
  import { fn } from 'storybook/test';
3
-
13
+ import { FormField } from 'Components/formField/FormField';
4
14
  import { TextArea } from './TextArea';
5
15
 
6
- const meta: Meta<typeof TextArea> = {
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 **TextArea** is the multi-line text entry control in the Arbor design system.',
23
+ 'It wraps a native `<textarea>` element with an optional auto-grow behaviour,',
24
+ 'an error state, and full pass-through of HTML textarea attributes.',
25
+ ].join('\n');
26
+
27
+ const USAGE_GUIDANCE = [
28
+ '### When to use',
29
+ '',
30
+ '- Multi-line free text: pupil notes, incident reports, medical conditions, absence reasons, exclusion summaries',
31
+ '- Any field where the user needs room to write more than one sentence',
32
+ '- As the underlying input inside a `FormField` with `inputType="textarea"` (the recommended pattern in real forms)',
33
+ '',
34
+ '---',
35
+ '',
36
+ '### When NOT to use',
37
+ '',
38
+ '| Instead of TextArea... | Use... | Why |',
39
+ '|---|---|---|',
40
+ '| Single-line short text | [`TextInput`](?path=/docs/components-formfield-inputs-textinput--docs) | Smaller footprint; correct affordance for short values |',
41
+ '| Numeric values | [`NumberInput`](?path=/docs/components-formfield-inputs-numberinput--docs) | Precision handling, stepper UI |',
42
+ '| Date selection | `DatePicker` | Calendar UI; avoids inconsistent native date picker chrome |',
43
+ '| Selecting from a list | `SelectDropdown` or `Combobox` | Constrained selection; better keyboard and screen reader UX |',
44
+ '',
45
+ '---',
46
+ '',
47
+ '### Design guidance',
48
+ '',
49
+ '- **Width is always 100%** — the textarea fills its container. Control width by sizing the wrapper.',
50
+ '- **Use `rows` to set the initial height** — a value of 3–5 suits most school management forms.',
51
+ '- **`autoSize` (default `true`) grows the textarea as the user types** — prefer this over a fixed height so users can see their full input without scrolling inside the box.',
52
+ '- **Use `FormField` in real forms** — `FormField` automatically wires `hasError`, `aria-invalid`, and `aria-describedby`.',
53
+ ' On a bare `TextArea`, you are responsible for those attributes yourself.',
54
+ ].join('\n');
55
+
56
+ const DEVELOPER_NOTES = [
57
+ '### Critical gotchas',
58
+ '',
59
+ '#### 1. `autoSize` mechanics',
60
+ 'When `autoSize={true}` (the default), the component reads `scrollHeight` on every `onChange` event',
61
+ 'and sets `height = scrollHeight + 4px` (vertical padding compensation).',
62
+ 'The textarea still starts at the height implied by the `rows` prop — growth begins after the first keystroke.',
63
+ 'Importantly, `autoSize` is triggered by `onChange`, which means it only fires when the user types.',
64
+ 'Setting a large `defaultValue` at mount will NOT auto-grow; call layout imperatively if needed.',
65
+ '',
66
+ '#### 2. CSS `resize` is NOT controlled by this component',
67
+ 'The browser default (`resize: both` or `resize: vertical` depending on browser) applies.',
68
+ 'Consumers override it directly on the element:',
69
+ '',
70
+ '```tsx',
71
+ '// Prevent user resizing entirely',
72
+ '<TextArea style={{ resize: "none" }} />',
73
+ '',
74
+ '// Or via a CSS class you own',
75
+ '<TextArea className="my-fixed-textarea" />',
76
+ '```',
77
+ '',
78
+ '#### 3. No `forwardRef` — consumers cannot attach a ref',
79
+ 'TextArea uses an internal `ref` for `autoSize` calculations.',
80
+ 'It is NOT a `forwardRef` component — you cannot attach a `ref` to it from the outside.',
81
+ 'If you need imperative focus control, wrap the TextArea and use a separate DOM query,',
82
+ 'or request `forwardRef` support as a library enhancement.',
83
+ '',
84
+ '#### 4. `size` prop is intentionally omitted',
85
+ 'The HTML `size` attribute is omitted from the props (the TypeScript type uses `Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size">`).',
86
+ 'Use `rows` to control the initial height; control width by sizing the parent wrapper.',
87
+ '',
88
+ '#### 5. `aria-invalid` is NOT automatic on bare TextArea',
89
+ '`hasError={true}` only adds the visual red border class (`ds-input--error`).',
90
+ 'It carries no ARIA semantics. When using TextArea outside FormField,',
91
+ 'you must also pass `aria-invalid="true"` so assistive technologies announce the error state.',
92
+ 'FormField handles this automatically when `errorText` is present.',
93
+ '',
94
+ '```tsx',
95
+ '// BAD — visually red, but screen reader does not know there is an error',
96
+ '<TextArea hasError />',
97
+ '',
98
+ '// GOOD — both the visual class and the ARIA attribute are set',
99
+ '<TextArea hasError aria-invalid="true" aria-describedby="my-field-error" />',
100
+ '<span id="my-field-error">Enter a valid absence reason</span>',
101
+ '```',
102
+ '',
103
+ '---',
104
+ '',
105
+ '### Accessibility',
106
+ '',
107
+ '- **Label association** — always wire `id` on the textarea and `htmlFor` on the label.',
108
+ ' `FormField` does this automatically; bare TextArea consumers must do it manually.',
109
+ '- **Error state** — pair `hasError={true}` with `aria-invalid="true"` and',
110
+ ' `aria-describedby` pointing to the error message element.',
111
+ ' `FormField` handles all of this automatically when `errorText` is present.',
112
+ '- **Description text** — use `aria-describedby` pointing to any helper text element.',
113
+ ' `FormField` wires this via `${id}-description` automatically.',
114
+ '- **disabled vs readOnly** — `disabled` removes the field from tab order entirely.',
115
+ ' Use `readOnly` instead when the value must remain accessible and copyable,',
116
+ ' or when a screen reader user needs to reach the field.',
117
+ '- **Focus ring** — 3px outline using `--focus-border` and `--color-brand-500`.',
118
+ ' Do not suppress focus rings — they are the primary keyboard navigation cue.',
119
+ '- **Placeholder is not a label** — never use placeholder as the only visible label.',
120
+ ' It disappears on input and has poor colour contrast by default.',
121
+ '- **Character limits** — when using `maxLength`, always pair with a visible character counter.',
122
+ ' Screen reader users cannot see the browser-native character limit indicator.',
123
+ '',
124
+ '---',
125
+ '',
126
+ '### TypeScript types',
127
+ '',
128
+ '`TextArea` exports a namespace with the `Props` type:',
129
+ '',
130
+ '```tsx',
131
+ "import { TextArea } from '@arbor-education/design-system.components';",
132
+ '',
133
+ 'type Props = TextArea.Props;',
134
+ '```',
135
+ '',
136
+ '| Type | Description |',
137
+ '|---|---|',
138
+ '| `TextArea.Props` | Full props interface — `hasError`, `autoSize`, plus all `TextareaHTMLAttributes<HTMLTextAreaElement>` (except `size`) |',
139
+ ].join('\n');
140
+
141
+ const RELATED_COMPONENTS = [
142
+ '## Related components',
143
+ '',
144
+ '[FormField](?path=/docs/components-formfield--docs)',
145
+ '· [TextInput](?path=/docs/components-formfield-inputs-textinput--docs)',
146
+ '· [NumberInput](?path=/docs/components-formfield-inputs-numberinput--docs)',
147
+ ].join('\n');
148
+
149
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Docs page
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function TextAreaDocsPage() {
156
+ return (
157
+ <>
158
+ <Title />
159
+ <Subtitle />
160
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
161
+ <DocHeading>Interactive example</DocHeading>
162
+ <Markdown>{PROPS_INTRO}</Markdown>
163
+ <DocPrimary />
164
+ <Controls />
165
+ <DocHeading>Usage guidance</DocHeading>
166
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
167
+ <DocHeading>Developer notes</DocHeading>
168
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
169
+ <DocHeading>Examples</DocHeading>
170
+ <Stories title="" />
171
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
172
+ </>
173
+ );
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Meta
178
+ // ---------------------------------------------------------------------------
179
+
180
+ const meta = {
7
181
  title: 'Components/FormField/Inputs/TextArea',
8
182
  component: TextArea,
9
- };
10
-
11
- export const Default = {
183
+ tags: ['autodocs'],
184
+ parameters: {
185
+ layout: 'centered',
186
+ docs: { page: TextAreaDocsPage },
187
+ },
188
+ argTypes: {
189
+ hasError: {
190
+ control: 'boolean',
191
+ description: [
192
+ 'Applies the `ds-input--error` class, which sets a red border and error text colour.',
193
+ 'When using TextArea inside `FormField`, this prop is set automatically when `errorText` is present',
194
+ '— do NOT pass it manually in `inputProps`.',
195
+ 'When using bare TextArea, also pass `aria-invalid="true"` — `hasError` is purely visual',
196
+ 'and carries no ARIA semantics on its own.',
197
+ ].join(' '),
198
+ table: {
199
+ type: { summary: 'boolean' },
200
+ defaultValue: { summary: 'false' },
201
+ },
202
+ },
203
+ autoSize: {
204
+ control: 'boolean',
205
+ description: [
206
+ 'When `true` (the default), the textarea automatically grows vertically as the user types.',
207
+ 'Height is recalculated on each `onChange` event: `scrollHeight + 4px` (vertical padding compensation).',
208
+ 'Initial height is still determined by the `rows` prop.',
209
+ 'When `false`, the textarea has a fixed height and the browser resize handle is available.',
210
+ 'Use `false` for forms where a consistent field height is important (e.g. data tables).',
211
+ ].join(' '),
212
+ table: {
213
+ type: { summary: 'boolean' },
214
+ defaultValue: { summary: 'true' },
215
+ },
216
+ },
217
+ disabled: {
218
+ control: 'boolean',
219
+ description: [
220
+ 'Native HTML disabled attribute. Removes the field from tab order entirely.',
221
+ 'Prevents all interaction and excludes the value from form submission.',
222
+ 'Use `readOnly` instead when the value must remain accessible, copyable, or submitted.',
223
+ ].join(' '),
224
+ table: {
225
+ type: { summary: 'boolean' },
226
+ defaultValue: { summary: 'false' },
227
+ },
228
+ },
229
+ rows: {
230
+ control: 'number',
231
+ description: [
232
+ 'Sets the initial visible height of the textarea as a number of text rows.',
233
+ 'Recommended default: 3 for short notes, 5 for incident reports or medical summaries.',
234
+ 'When `autoSize` is true, the textarea grows beyond this initial height as the user types.',
235
+ 'When `autoSize` is false, this is the fixed height (unless the user drags the resize handle).',
236
+ ].join(' '),
237
+ table: {
238
+ type: { summary: 'number' },
239
+ },
240
+ },
241
+ placeholder: {
242
+ control: 'text',
243
+ description: [
244
+ 'Hint text displayed when the field is empty.',
245
+ 'Never use placeholder as the only visible label — it disappears on input',
246
+ 'and has poor colour contrast by default. Always pair with a visible label or `aria-label`.',
247
+ ].join(' '),
248
+ table: {
249
+ type: { summary: 'string' },
250
+ },
251
+ },
252
+ maxLength: {
253
+ control: 'number',
254
+ description: [
255
+ 'Maximum number of characters allowed, enforced natively by the browser.',
256
+ 'Useful for fields with a hard character limit — short notes, reference fields.',
257
+ 'Always pair with a visible character counter so users know how much space remains.',
258
+ 'Screen reader users cannot see the browser-native character limit indicator.',
259
+ ].join(' '),
260
+ table: {
261
+ type: { summary: 'number' },
262
+ },
263
+ },
264
+ value: {
265
+ control: 'text',
266
+ description: [
267
+ 'Controlled value — pair with `onChange` to manage state externally.',
268
+ 'When using controlled mode, you must handle `onChange` otherwise the textarea will appear frozen.',
269
+ 'For uncontrolled usage, use `defaultValue` instead.',
270
+ ].join(' '),
271
+ table: {
272
+ type: { summary: 'string' },
273
+ },
274
+ },
275
+ defaultValue: {
276
+ control: 'text',
277
+ description: [
278
+ 'Uncontrolled initial value. The textarea manages its own state after mount.',
279
+ 'Do not combine with `value` — use one or the other.',
280
+ 'Note: setting a large `defaultValue` at mount will NOT trigger `autoSize`.',
281
+ 'The textarea starts at the height set by `rows` regardless of the initial value length.',
282
+ ].join(' '),
283
+ table: {
284
+ type: { summary: 'string' },
285
+ },
286
+ },
287
+ readOnly: {
288
+ control: 'boolean',
289
+ description: [
290
+ 'Makes the field focusable and selectable but not editable.',
291
+ 'The value IS included in form submission (unlike `disabled`).',
292
+ 'The field remains in tab order — use this instead of `disabled` when the value',
293
+ 'must be accessible, readable, or copyable by the user.',
294
+ ].join(' '),
295
+ table: {
296
+ type: { summary: 'boolean' },
297
+ },
298
+ },
299
+ onChange: {
300
+ description: [
301
+ 'Change event handler. Fires on every keystroke.',
302
+ 'Required when using controlled `value` — otherwise the textarea will not update.',
303
+ 'Unlike `NumberInput`, this is a real native `ChangeEvent<HTMLTextAreaElement>` —',
304
+ 'both `e.target.value` and `e.currentTarget.value` are populated.',
305
+ ].join(' '),
306
+ table: {
307
+ type: { summary: '(e: ChangeEvent<HTMLTextAreaElement>) => void' },
308
+ },
309
+ },
310
+ className: {
311
+ control: 'text',
312
+ description: [
313
+ 'CSS class applied to the `<textarea>` element.',
314
+ 'Use to override resize behaviour — e.g. a class with `resize: none` —',
315
+ 'or to apply custom styles. The `ds-textarea` and `ds-input` base classes are always applied.',
316
+ 'You can also pass `style={{ resize: "none" }}` directly as an HTML attribute.',
317
+ ].join(' '),
318
+ table: {
319
+ type: { summary: 'string' },
320
+ },
321
+ },
322
+ },
12
323
  args: {
13
- title: 'titleValue',
14
324
  onChange: fn(),
15
325
  },
16
- };
326
+ } satisfies Meta<typeof TextArea>;
17
327
 
18
328
  export default meta;
329
+ type Story = StoryObj<typeof TextArea>;
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Helper: attach a per-story description to docs
333
+ // ---------------------------------------------------------------------------
334
+ const withDescription = (story: Story, description: string): Story => ({
335
+ ...story,
336
+ parameters: {
337
+ ...story.parameters,
338
+ docs: { ...story.parameters?.docs, description: { story: description } },
339
+ },
340
+ });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Stateful template components
344
+ // Named components avoid hooks-in-callbacks lint issues (react-hooks plugin
345
+ // is NOT configured in this project — do not add eslint-disable comments).
346
+ // ---------------------------------------------------------------------------
347
+
348
+ const ControlledWithCharacterCountTemplate = () => {
349
+ const maxLength = 500;
350
+ const [value, setValue] = useState('');
351
+
352
+ const remaining = maxLength - value.length;
353
+ const isApproaching = remaining <= 100 && remaining > 0;
354
+ const isAtLimit = remaining <= 0;
355
+
356
+ const countColour = isAtLimit
357
+ ? 'var(--color-semantic-destructive-600)'
358
+ : isApproaching
359
+ ? 'var(--color-semantic-caution-600)'
360
+ : 'var(--color-grey-600)';
361
+
362
+ return (
363
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
364
+ <label htmlFor="incident-notes" style={{ color: 'var(--color-grey-600)' }}>
365
+ Incident report notes
366
+ </label>
367
+ <TextArea
368
+ id="incident-notes"
369
+ rows={5}
370
+ maxLength={maxLength}
371
+ placeholder="Describe the incident, including time, location, and witnesses..."
372
+ value={value}
373
+ onChange={e => setValue(e.currentTarget.value)}
374
+ aria-describedby="incident-notes-count"
375
+ />
376
+ <span
377
+ id="incident-notes-count"
378
+ style={{
379
+ fontSize: 'var(--font-size-1-11)',
380
+ color: countColour,
381
+ textAlign: 'right',
382
+ }}
383
+ >
384
+ {remaining}
385
+ {' '}
386
+ character
387
+ {remaining !== 1 ? 's' : ''}
388
+ {' '}
389
+ remaining
390
+ </span>
391
+ </div>
392
+ );
393
+ };
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Stories
397
+ // ---------------------------------------------------------------------------
398
+
399
+ export const Default: Story = withDescription(
400
+ {
401
+ args: {
402
+ placeholder: 'Enter pupil notes here...',
403
+ rows: 3,
404
+ autoSize: true,
405
+ disabled: false,
406
+ hasError: false,
407
+ },
408
+ render: args => <TextArea {...args} />,
409
+ },
410
+ 'The interactive canvas story — every prop is wired to the **Controls** panel. Use the controls to explore error state, disabled, autoSize, placeholder, rows, and other HTML textarea attributes.',
411
+ );
412
+
413
+ export const WithError: Story = withDescription(
414
+ {
415
+ render: () => (
416
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
417
+ <label htmlFor="absence-reason-error" style={{ color: 'var(--color-grey-600)' }}>
418
+ Absence reason
419
+ </label>
420
+ <TextArea
421
+ id="absence-reason-error"
422
+ rows={3}
423
+ hasError
424
+ aria-invalid="true"
425
+ aria-describedby="absence-reason-error-msg"
426
+ defaultValue=""
427
+ placeholder="Enter the reason for absence..."
428
+ />
429
+ <span
430
+ id="absence-reason-error-msg"
431
+ style={{ color: 'var(--color-semantic-destructive-600)' }}
432
+ >
433
+ An absence reason is required before this record can be saved.
434
+ </span>
435
+ </div>
436
+ ),
437
+ parameters: {
438
+ docs: {
439
+ source: {
440
+ language: 'tsx',
441
+ code: [
442
+ "import { TextArea } from '@arbor-education/design-system.components';",
443
+ '',
444
+ 'function WithErrorExample() {',
445
+ ' return (',
446
+ ' <div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-xsmall)" }}>',
447
+ ' <label htmlFor="absence-reason-error" style={{ color: "var(--color-grey-600)" }}>',
448
+ ' Absence reason',
449
+ ' </label>',
450
+ ' <TextArea',
451
+ ' id="absence-reason-error"',
452
+ ' rows={3}',
453
+ ' hasError',
454
+ ' aria-invalid="true"',
455
+ ' aria-describedby="absence-reason-error-msg"',
456
+ ' defaultValue=""',
457
+ ' placeholder="Enter the reason for absence..."',
458
+ ' />',
459
+ ' <span',
460
+ ' id="absence-reason-error-msg"',
461
+ ' style={{ color: "var(--color-semantic-destructive-600)" }}',
462
+ ' >',
463
+ ' An absence reason is required before this record can be saved.',
464
+ ' </span>',
465
+ ' </div>',
466
+ ' );',
467
+ '}',
468
+ 'export default WithErrorExample;',
469
+ ].join('\n'),
470
+ },
471
+ },
472
+ },
473
+ },
474
+ [
475
+ 'Shows `hasError={true}` — the red border (`ds-input--error` class) combined with `aria-invalid="true"`',
476
+ 'and `aria-describedby` pointing to the error message. On a bare TextArea,',
477
+ '**you must provide all three**. `hasError` alone is purely visual and has no ARIA semantics.',
478
+ '`FormField` handles this wiring automatically when `errorText` is present.',
479
+ ].join(' '),
480
+ );
481
+
482
+ export const Disabled: Story = withDescription(
483
+ {
484
+ render: () => (
485
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
486
+ <label
487
+ htmlFor="medical-notes-disabled"
488
+ style={{ color: 'var(--color-grey-400)' }}
489
+ >
490
+ Medical information (locked)
491
+ </label>
492
+ <TextArea
493
+ id="medical-notes-disabled"
494
+ rows={4}
495
+ disabled
496
+ defaultValue="Pupil has a documented nut allergy (anaphylaxis risk). EpiPen is held in the school office. All catering staff have been briefed."
497
+ />
498
+ </div>
499
+ ),
500
+ parameters: {
501
+ docs: {
502
+ source: {
503
+ language: 'tsx',
504
+ code: [
505
+ "import { TextArea } from '@arbor-education/design-system.components';",
506
+ '',
507
+ 'function DisabledExample() {',
508
+ ' return (',
509
+ ' <div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-xsmall)" }}>',
510
+ ' <label',
511
+ ' htmlFor="medical-notes-disabled"',
512
+ ' style={{ color: "var(--color-grey-400)" }}',
513
+ ' >',
514
+ ' Medical information (locked)',
515
+ ' </label>',
516
+ ' <TextArea',
517
+ ' id="medical-notes-disabled"',
518
+ ' rows={4}',
519
+ ' disabled',
520
+ ' defaultValue="Pupil has a documented nut allergy (anaphylaxis risk). EpiPen is held in the school office. All catering staff have been briefed."',
521
+ ' />',
522
+ ' </div>',
523
+ ' );',
524
+ '}',
525
+ 'export default DisabledExample;',
526
+ ].join('\n'),
527
+ },
528
+ },
529
+ },
530
+ },
531
+ [
532
+ '`disabled={true}` — the field is greyed out, removed from the tab order, and its value is excluded from form submission.',
533
+ 'The label colour is shifted to `--color-grey-400` to reflect the inactive state.',
534
+ 'Use `readOnly` instead when the value must remain accessible, copyable, or submitted with the form.',
535
+ ].join(' '),
536
+ );
537
+
538
+ export const FixedHeight: Story = withDescription(
539
+ {
540
+ render: () => (
541
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
542
+ <label htmlFor="exclusion-summary-fixed" style={{ color: 'var(--color-grey-600)' }}>
543
+ Exclusion summary
544
+ </label>
545
+ <TextArea
546
+ id="exclusion-summary-fixed"
547
+ rows={5}
548
+ autoSize={false}
549
+ style={{ resize: 'none' }}
550
+ placeholder="Summarise the exclusion circumstances, duration, and any meetings held with parents..."
551
+ />
552
+ <span style={{ fontSize: 'var(--font-size-1-11)', color: 'var(--color-grey-600)' }}>
553
+ Fixed height — scroll inside the box to read long entries.
554
+ </span>
555
+ </div>
556
+ ),
557
+ parameters: {
558
+ docs: {
559
+ source: {
560
+ language: 'tsx',
561
+ code: [
562
+ "import { TextArea } from '@arbor-education/design-system.components';",
563
+ '',
564
+ 'function FixedHeightExample() {',
565
+ ' return (',
566
+ ' <TextArea',
567
+ ' id="exclusion-summary"',
568
+ ' rows={5}',
569
+ ' autoSize={false}',
570
+ ' style={{ resize: "none" }}',
571
+ ' placeholder="Summarise the exclusion circumstances..."',
572
+ ' />',
573
+ ' );',
574
+ '}',
575
+ 'export default FixedHeightExample;',
576
+ ].join('\n'),
577
+ },
578
+ },
579
+ },
580
+ },
581
+ [
582
+ '`autoSize={false}` — the textarea has a fixed height set by `rows`. The browser resize handle is hidden',
583
+ 'via `style={{ resize: "none" }}` (CSS `resize` is not controlled by the component itself — consumers',
584
+ 'override it directly). Use a fixed height in data table cells, compact admin forms, or anywhere',
585
+ 'a consistent field height matters more than full content visibility.',
586
+ ].join(' '),
587
+ );
588
+
589
+ export const ReadOnly: Story = withDescription(
590
+ {
591
+ render: () => (
592
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
593
+ <label htmlFor="sen-notes-readonly" style={{ color: 'var(--color-grey-600)' }}>
594
+ SEN support plan notes (read only)
595
+ </label>
596
+ <TextArea
597
+ id="sen-notes-readonly"
598
+ rows={4}
599
+ readOnly
600
+ defaultValue="Pupil receives 1:1 literacy support three times per week. Extra time (25%) applies in all timed assessments. Seating near the front of class is recommended. Review due: end of Spring term."
601
+ />
602
+ </div>
603
+ ),
604
+ parameters: {
605
+ docs: {
606
+ source: {
607
+ language: 'tsx',
608
+ code: [
609
+ "import { TextArea } from '@arbor-education/design-system.components';",
610
+ '',
611
+ 'function ReadOnlyExample() {',
612
+ ' return (',
613
+ ' <div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-xsmall)" }}>',
614
+ ' <label htmlFor="sen-notes-readonly" style={{ color: "var(--color-grey-600)" }}>',
615
+ ' SEN support plan notes (read only)',
616
+ ' </label>',
617
+ ' <TextArea',
618
+ ' id="sen-notes-readonly"',
619
+ ' rows={4}',
620
+ ' readOnly',
621
+ ' defaultValue="Pupil receives 1:1 literacy support three times per week. Extra time (25%) applies in all timed assessments. Seating near the front of class is recommended. Review due: end of Spring term."',
622
+ ' />',
623
+ ' </div>',
624
+ ' );',
625
+ '}',
626
+ 'export default ReadOnlyExample;',
627
+ ].join('\n'),
628
+ },
629
+ },
630
+ },
631
+ },
632
+ [
633
+ '`readOnly={true}` — the field is focusable and selectable (the user can copy the content)',
634
+ 'but not editable. The value IS included in form submission, unlike `disabled`.',
635
+ 'The field remains in the tab order. Use `readOnly` when displaying a value that should be',
636
+ 'visible and copyable — SEN plans, locked incident summaries, pre-filled template text.',
637
+ ].join(' '),
638
+ );
639
+
640
+ export const WithFormField: Story = withDescription(
641
+ {
642
+ render: () => (
643
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
644
+ <FormField
645
+ inputType="textarea"
646
+ label="Pupil notes"
647
+ id="pupil-notes"
648
+ fieldDescription="Add any pastoral or academic notes visible to form tutors."
649
+ inputProps={{ rows: 3, placeholder: 'Enter notes about this pupil...' }}
650
+ />
651
+ <FormField
652
+ inputType="textarea"
653
+ label="Absence reason"
654
+ id="absence-reason"
655
+ errorText="An absence reason is required before this record can be saved."
656
+ inputProps={{ rows: 3, placeholder: 'Enter the reason for absence...' }}
657
+ />
658
+ </div>
659
+ ),
660
+ parameters: {
661
+ docs: {
662
+ source: {
663
+ language: 'tsx',
664
+ code: [
665
+ "import { FormField } from '@arbor-education/design-system.components';",
666
+ '',
667
+ 'function WithFormFieldExample() {',
668
+ ' return (',
669
+ ' <div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-large)" }}>',
670
+ ' <FormField',
671
+ ' inputType="textarea"',
672
+ ' label="Pupil notes"',
673
+ ' id="pupil-notes"',
674
+ ' fieldDescription="Add any pastoral or academic notes visible to form tutors."',
675
+ ' inputProps={{ rows: 3, placeholder: "Enter notes about this pupil..." }}',
676
+ ' />',
677
+ ' <FormField',
678
+ ' inputType="textarea"',
679
+ ' label="Absence reason"',
680
+ ' id="absence-reason"',
681
+ ' errorText="An absence reason is required before this record can be saved."',
682
+ ' inputProps={{ rows: 3, placeholder: "Enter the reason for absence..." }}',
683
+ ' />',
684
+ ' </div>',
685
+ ' );',
686
+ '}',
687
+ 'export default WithFormFieldExample;',
688
+ ].join('\n'),
689
+ },
690
+ },
691
+ },
692
+ },
693
+ [
694
+ '**Always use `FormField` in real forms.** `FormField` with `inputType="textarea"` wraps TextArea and',
695
+ 'automatically wires `hasError`, `aria-invalid`, `aria-describedby`, and the label `htmlFor` association.',
696
+ 'The second example shows how `errorText` automatically sets the error state — do NOT pass',
697
+ '`hasError` or `aria-invalid` manually in `inputProps` when using FormField.',
698
+ 'The first example shows `fieldDescription`, which renders helper text below the label.',
699
+ ].join(' '),
700
+ );
701
+
702
+ export const ControlledWithCharacterCount: Story = withDescription(
703
+ {
704
+ render: () => <ControlledWithCharacterCountTemplate />,
705
+ parameters: {
706
+ docs: {
707
+ source: {
708
+ language: 'tsx',
709
+ code: [
710
+ "import { useState } from 'react';",
711
+ "import { TextArea } from '@arbor-education/design-system.components';",
712
+ '',
713
+ 'function IncidentNotesField() {',
714
+ ' const maxLength = 500;',
715
+ ' const [value, setValue] = useState("");',
716
+ '',
717
+ ' const remaining = maxLength - value.length;',
718
+ ' const isApproaching = remaining <= 100 && remaining > 0;',
719
+ ' const isAtLimit = remaining <= 0;',
720
+ '',
721
+ ' const countColour = isAtLimit',
722
+ ' ? "var(--color-semantic-destructive-600)"',
723
+ ' : isApproaching',
724
+ ' ? "var(--color-semantic-caution-600)"',
725
+ ' : "var(--color-grey-600)";',
726
+ '',
727
+ ' return (',
728
+ ' <div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-xsmall)" }}>',
729
+ ' <label htmlFor="incident-notes">Incident report notes</label>',
730
+ ' <TextArea',
731
+ ' id="incident-notes"',
732
+ ' rows={5}',
733
+ ' maxLength={maxLength}',
734
+ ' placeholder="Describe the incident..."',
735
+ ' value={value}',
736
+ ' onChange={(e) => setValue(e.currentTarget.value)}',
737
+ ' aria-describedby="incident-notes-count"',
738
+ ' />',
739
+ ' <span',
740
+ ' id="incident-notes-count"',
741
+ ' style={{',
742
+ ' fontSize: "var(--font-size-1-11)",',
743
+ ' color: countColour,',
744
+ ' textAlign: "right",',
745
+ ' }}',
746
+ ' >',
747
+ ' {remaining} character{remaining !== 1 ? "s" : ""} remaining',
748
+ ' </span>',
749
+ ' </div>',
750
+ ' );',
751
+ '}',
752
+ 'export default IncidentNotesField;',
753
+ ].join('\n'),
754
+ },
755
+ },
756
+ },
757
+ },
758
+ [
759
+ 'A fully controlled textarea with a live character counter.',
760
+ '`value` and `onChange` manage state externally (note: use `e.currentTarget.value` — it is always populated).',
761
+ 'The counter colour progresses through three states using design tokens:',
762
+ '`--color-grey-600` (neutral) → `--color-semantic-caution-600` (amber, within 100 of limit) → `--color-semantic-destructive-600` (red, at limit).',
763
+ '`aria-describedby` wires the counter to the textarea so screen readers announce the remaining count.',
764
+ 'The browser-enforced `maxLength` silently truncates beyond the limit — always pair it with a visible counter.',
765
+ ].join(' '),
766
+ );