@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,11 +1,167 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import {
3
+ Controls,
4
+ Heading as DocHeading,
5
+ Markdown,
6
+ Primary as DocPrimary,
7
+ Stories,
8
+ Subtitle,
9
+ Title,
10
+ } from '@storybook/addon-docs/blocks';
11
+ import { useState, useRef } from 'react';
2
12
  import { Button } from './Button';
3
13
 
14
+ // ---------------------------------------------------------------------------
15
+ // Component description — built as a joined array to avoid no-useless-escape
16
+ // on backtick code spans inside template literals.
17
+ //
18
+ // The global DocsTemplate renders the description BEFORE the Primary story
19
+ // and renders parameters.docs.relatedComponents as a markdown block at the
20
+ // very bottom of the page (after Stories) — so "Related components" does NOT
21
+ // belong in this description.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const DESCRIPTION_INTRO = [
25
+ 'The **Button** is the primary interactive control in the Arbor design system. It wraps a native',
26
+ '`<button>` element with `forwardRef` support, consistent design tokens, icon slots, and a full set',
27
+ 'of variant and state modifiers.',
28
+ ].join('\n');
29
+
30
+ const USAGE_GUIDANCE = [
31
+ '### When to use',
32
+ '',
33
+ '| Variant | Use when |',
34
+ '|---|---|',
35
+ '| `primary` | The single most important action in a page section — Save, Confirm, Continue |',
36
+ '| `secondary` | A supporting or alternative action — Cancel, Back, Edit |',
37
+ '| `tertiary` | A quiet, low-priority action — Clear filters, Reset, optional utility actions |',
38
+ '| `primary-destructive` | An irreversible, high-impact action — Delete permanently, Expel student |',
39
+ '| `secondary-destructive` | A softer destructive action that may have a confirmation step — Archive record |',
40
+ '| `text-link` | An inline contextual action embedded in a sentence or label |',
41
+ '| `dropdown` | A button styled as a dropdown trigger — uses `justify-content: space-between` so a trailing chevron icon aligns to the right edge. Used internally by `SelectDropdown`, `ColourPickerDropdown`, and `UserDropdown`, and available to consumers for custom dropdown triggers. |',
42
+ '',
43
+ '---',
44
+ '',
45
+ '### When NOT to use',
46
+ '',
47
+ '| Instead of Button... | Use... | Why |',
48
+ '|---|---|---|',
49
+ '| URL navigation | `<a>` tag | Semantic: links navigate, buttons act |',
50
+ '| Tab switching | [`Tabs`](?path=/docs/components-tabs--docs) | Correct `role="tab"` semantics |',
51
+ '| Filter selection | [`Pill`](?path=/docs/components-pill--docs) | Purpose-built toggle for filter UI |',
52
+ '| Boolean on/off | [`Toggle`](?path=/docs/components-toggle--docs) or [`CheckboxInput`](?path=/docs/components-formfield-inputs-checkbox--docs) | Communicates checked state via ARIA |',
53
+ '',
54
+ '---',
55
+ '',
56
+ '### Design guidance',
57
+ '',
58
+ '- **One primary per section** — using multiple primary buttons in the same form or toolbar dilutes',
59
+ ' the visual hierarchy. Pick the single most important action.',
60
+ '- **Destructive tier** — use `primary-destructive` for permanent, unrecoverable operations (delete a',
61
+ ' student record, permanently remove data). Use `secondary-destructive` for softer operations that',
62
+ ' may have a confirmation step or are partially reversible (archive, suspend).',
63
+ '- **Tertiary** is for quiet utility actions. Do not use it as a "Cancel" button next to a Primary —',
64
+ ' use `secondary` for Cancel so the hierarchy reads clearly.',
65
+ '- **text-link** is for inline contextual actions. Do not use it as a Cancel in a confirm/cancel row.',
66
+ ].join('\n');
67
+
68
+ const DEVELOPER_NOTES = [
69
+ '### Critical gotchas',
70
+ '',
71
+ '#### 1. The `type` attribute trap',
72
+ 'The `type` prop is **deliberately omitted** from the component\'s prop spread',
73
+ '(`Omit<ButtonHTMLAttributes<HTMLButtonElement>, \'type\' | \'onClick\'>` on `Button.tsx` line 24).',
74
+ 'This means the underlying `<button>` has no `type` attribute, and browsers default to',
75
+ '`type="submit"`. If this button lives inside a `<form>`, clicking it **will submit the form**',
76
+ 'unless you explicitly pass `type="button"`.',
77
+ '',
78
+ '```tsx',
79
+ '// BAD — will submit any ancestor form',
80
+ '<Button variant="secondary" onClick={handleCancel}>Cancel</Button>',
81
+ '',
82
+ '// GOOD',
83
+ '<Button variant="secondary" type="button" onClick={handleCancel}>Cancel</Button>',
84
+ '```',
85
+ '',
86
+ '#### 2. Icon-only accessibility',
87
+ 'When `children` is omitted, the component enters icon-only mode (`ds-button--icon-only`). The icon',
88
+ 'name becomes the default screen reader text, but icon names like `"3-dot"` or `"x"` are not',
89
+ 'meaningful in context. Always provide `iconLeftScreenReaderText` or `iconRightScreenReaderText`.',
90
+ '',
91
+ '```tsx',
92
+ '// BAD — screen reader says "3-dot"',
93
+ '<Button variant="secondary" iconRightName="3-dot" />',
94
+ '',
95
+ '// GOOD',
96
+ '<Button variant="secondary" iconRightName="3-dot" iconRightScreenReaderText="More actions" />',
97
+ '```',
98
+ '',
99
+ '---',
100
+ '',
101
+ '### Accessibility',
102
+ '',
103
+ '- Native `<button>` — focusable with Tab, activated with Space or Enter',
104
+ '- Focus ring: 3px outline using `--focus-border` and `--color-brand-500` via `--focus-color-focus`',
105
+ '- `disabled` removes the button from tab order AND blocks pointer events (`opacity: 0.5; cursor: not-allowed; pointer-events: none`)',
106
+ '- Icon-only mode: provide `iconLeftScreenReaderText` / `iconRightScreenReaderText` for meaningful labels',
107
+ '- The `error` prop is **purely visual** (adds a red border) — it carries no ARIA semantics.',
108
+ ' The consumer is still responsible for communicating the error state to assistive tech — e.g.',
109
+ ' setting `aria-invalid` on the associated input or wiring `aria-describedby` to an error message.',
110
+ '',
111
+ '---',
112
+ '',
113
+ '### TypeScript types',
114
+ '',
115
+ '```ts',
116
+ "import { Button } from '@arbor-education/design-system.components';",
117
+ '',
118
+ 'function MyButton(props: Button.Props) { ... }',
119
+ '```',
120
+ '',
121
+ '| Type | Description |',
122
+ '|---|---|',
123
+ '| `Button.Props` | Full props interface |',
124
+ "| `Button.Variant` | `'primary' \\| 'secondary' \\| 'tertiary' \\| 'primary-destructive' \\| 'secondary-destructive' \\| 'text-link' \\| 'dropdown'` |",
125
+ "| `Button.Size` | `'M' \\| 'S'` |",
126
+ ].join('\n');
127
+
128
+ const RELATED_COMPONENTS = [
129
+ '## Related components',
130
+ '',
131
+ '[Icon](?path=/docs/components-icon--docs) · [Dropdown](?path=/docs/components-dropdown--docs) · [Pill](?path=/docs/components-pill--docs) · [Tabs](?path=/docs/components-tabs--docs) · [Toggle](?path=/docs/components-toggle--docs)',
132
+ ].join('\n');
133
+
134
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
135
+
136
+ function ButtonDocsPage() {
137
+ return (
138
+ <>
139
+ <Title />
140
+ <Subtitle />
141
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
142
+ <DocHeading>Interactive example</DocHeading>
143
+ <Markdown>{PROPS_INTRO}</Markdown>
144
+ <DocPrimary />
145
+ <Controls />
146
+ <DocHeading>Usage guidance</DocHeading>
147
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
148
+ <DocHeading>Developer notes</DocHeading>
149
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
150
+ <DocHeading>Examples</DocHeading>
151
+ <Stories title="" />
152
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
153
+ </>
154
+ );
155
+ }
156
+
4
157
  const meta = {
5
158
  title: 'Components/Button',
6
159
  component: Button,
7
160
  parameters: {
8
161
  layout: 'centered',
162
+ docs: {
163
+ page: ButtonDocsPage,
164
+ },
9
165
  },
10
166
  tags: ['autodocs'],
11
167
  argTypes: {
@@ -20,23 +176,205 @@ const meta = {
20
176
  'text-link',
21
177
  'dropdown',
22
178
  ],
23
- description: 'Button variant',
179
+ description: [
180
+ 'Visual style and semantic hierarchy of the button.',
181
+ '`primary` — main CTA (brand green background, white text).',
182
+ '`secondary` — supporting action (outlined, brand-coloured text).',
183
+ '`tertiary` — quiet utility action (text-only style).',
184
+ '`primary-destructive` — irreversible destructive (red background, white text).',
185
+ '`secondary-destructive` — softer destructive (red text on white, inverts on hover).',
186
+ '`text-link` — inline contextual action (underlined text, no box).',
187
+ '`dropdown` — styled as a dropdown trigger (chevron-friendly layout); used internally by SelectDropdown and friends, also available to consumers.',
188
+ ].join(' '),
189
+ table: {
190
+ type: { summary: "'primary' | 'secondary' | 'tertiary' | 'primary-destructive' | 'secondary-destructive' | 'text-link' | 'dropdown'" },
191
+ defaultValue: { summary: "'primary'" },
192
+ },
24
193
  },
25
194
  size: {
26
195
  control: 'select',
27
196
  options: ['M', 'S'],
28
- description: 'Button size',
197
+ description: [
198
+ 'Height of the button.',
199
+ '`M` resolves to `--size-medium` (2.25rem / 36px).',
200
+ '`S` resolves to `--size-small` (2rem / 32px).',
201
+ 'Note: the tertiary variant uses `--button-small-tertiary-*` tokens for both sizes',
202
+ 'because `--button-medium-tertiary-*` tokens do not exist — this is a known token gap.',
203
+ ].join(' '),
204
+ table: {
205
+ type: { summary: "'M' | 'S'" },
206
+ defaultValue: { summary: "'M'" },
207
+ },
208
+ },
209
+ children: {
210
+ control: 'text',
211
+ description: [
212
+ 'Button label content. Accepts any `React.ReactNode`.',
213
+ 'When omitted (and at least one icon prop is provided), the button enters',
214
+ 'icon-only mode (`ds-button--icon-only`) — square aspect ratio, no padding compensation needed.',
215
+ 'Always pair icon-only mode with descriptive `iconLeftScreenReaderText` / `iconRightScreenReaderText`.',
216
+ ].join(' '),
217
+ table: {
218
+ type: { summary: 'React.ReactNode' },
219
+ },
29
220
  },
30
221
  disabled: {
31
222
  control: 'boolean',
32
- description: 'Disable the button',
223
+ description: [
224
+ 'Native HTML disabled attribute.',
225
+ 'Applies `opacity: 0.5`, `cursor: not-allowed`, and `pointer-events: none`.',
226
+ 'Removes the button from tab order. Do NOT simulate disabled with manual opacity — use this prop.',
227
+ ].join(' '),
228
+ table: {
229
+ type: { summary: 'boolean' },
230
+ defaultValue: { summary: 'false' },
231
+ },
33
232
  },
34
- children: {
233
+ error: {
234
+ control: 'boolean',
235
+ description: [
236
+ 'Adds a red border (`--color-semantic-destructive-500`) via `ds-button--error`.',
237
+ 'Used by the `dropdown` variant to indicate form validation failure on a SelectDropdown field.',
238
+ 'This is purely visual — it adds no ARIA semantics.',
239
+ 'Consumers are responsible for communicating the error state to assistive tech (e.g. `aria-invalid` on the associated input, or `aria-describedby` pointing to an error message).',
240
+ ].join(' '),
241
+ table: {
242
+ type: { summary: 'boolean' },
243
+ defaultValue: { summary: 'false' },
244
+ },
245
+ },
246
+ hasHorizontalPadding: {
247
+ control: 'boolean',
248
+ description: [
249
+ 'When `false`, removes horizontal padding via `ds-button--no-horizontal-padding`.',
250
+ 'Use for flush-mounted buttons — e.g. a `text-link` variant sitting inline in a form label',
251
+ 'where you want the text to align with surrounding content without a left indent.',
252
+ 'Do not use as a general layout escape hatch.',
253
+ ].join(' '),
254
+ table: {
255
+ type: { summary: 'boolean' },
256
+ defaultValue: { summary: 'true' },
257
+ },
258
+ },
259
+ iconLeftName: {
260
+ control: 'select',
261
+ options: [
262
+ undefined,
263
+ 'plus',
264
+ 'pencil',
265
+ 'save',
266
+ 'trash',
267
+ 'download',
268
+ 'upload',
269
+ 'mail',
270
+ 'search',
271
+ 'info',
272
+ 'check',
273
+ 'x',
274
+ 'arrow-left',
275
+ 'arrow-right',
276
+ 'settings',
277
+ 'loader',
278
+ '3-dot',
279
+ 'user',
280
+ 'send',
281
+ ],
282
+ description: [
283
+ 'Name of an icon to render **before** the button label at 16px.',
284
+ 'Renders an `<Icon>` component with `size={16}`.',
285
+ 'Screen reader text defaults to the icon name — always override with `iconLeftScreenReaderText`',
286
+ 'when the icon name is not meaningful in context (e.g. `"3-dot"`, `"x"`).',
287
+ ].join(' '),
288
+ table: {
289
+ type: { summary: 'IconName' },
290
+ defaultValue: { summary: 'undefined' },
291
+ },
292
+ },
293
+ iconLeftScreenReaderText: {
35
294
  control: 'text',
36
- description: 'Button text',
295
+ description: [
296
+ 'Screen reader label for the left icon.',
297
+ 'Defaults to the icon name when omitted.',
298
+ 'In icon-only mode this becomes the accessible label for the entire button — make it descriptive.',
299
+ ].join(' '),
300
+ table: {
301
+ type: { summary: 'string' },
302
+ },
303
+ },
304
+ iconRightName: {
305
+ control: 'select',
306
+ options: [
307
+ undefined,
308
+ 'plus',
309
+ 'pencil',
310
+ 'save',
311
+ 'trash',
312
+ 'download',
313
+ 'upload',
314
+ 'mail',
315
+ 'search',
316
+ 'info',
317
+ 'check',
318
+ 'x',
319
+ 'arrow-right',
320
+ 'chevron-down',
321
+ 'chevron-up',
322
+ 'settings',
323
+ 'loader',
324
+ '3-dot',
325
+ 'external-link',
326
+ 'send',
327
+ ],
328
+ description: [
329
+ 'Name of an icon to render **after** the button label at 16px.',
330
+ 'Renders an `<Icon>` component with `size={16}`.',
331
+ 'Screen reader text defaults to the icon name — always override with `iconRightScreenReaderText`.',
332
+ ].join(' '),
333
+ table: {
334
+ type: { summary: 'IconName' },
335
+ defaultValue: { summary: 'undefined' },
336
+ },
337
+ },
338
+ iconRightScreenReaderText: {
339
+ control: 'text',
340
+ description: [
341
+ 'Screen reader label for the right icon.',
342
+ 'Defaults to the icon name when omitted.',
343
+ 'In icon-only mode this is the accessible label — always set it explicitly.',
344
+ ].join(' '),
345
+ table: {
346
+ type: { summary: 'string' },
347
+ },
348
+ },
349
+ borderless: {
350
+ control: 'boolean',
351
+ description: [
352
+ 'Removes the border entirely via `ds-button--borderless`.',
353
+ 'For toolbar and icon-button contexts where a visible border would be visually noisy.',
354
+ 'Pairs well with `variant="tertiary"` and an icon.',
355
+ ].join(' '),
356
+ table: {
357
+ type: { summary: 'boolean' },
358
+ defaultValue: { summary: 'false' },
359
+ },
37
360
  },
38
361
  onClick: {
39
- action: 'clicked',
362
+ description: [
363
+ 'Click handler. Receives the `React.MouseEvent` as the first argument.',
364
+ 'Additional spread arguments are accepted (non-standard) to support legacy callers.',
365
+ 'Wire to the Controls action logger with `fn()` in meta.',
366
+ ].join(' '),
367
+ table: {
368
+ type: { summary: '(event: React.MouseEvent<HTMLButtonElement>, ...otherArgs: unknown[]) => void' },
369
+ },
370
+ },
371
+ className: {
372
+ control: 'text',
373
+ description: 'Additional CSS class names appended to the button element. Use sparingly — prefer variant and size props.',
374
+ table: {
375
+ type: { summary: 'string' },
376
+ defaultValue: { summary: "''" },
377
+ },
40
378
  },
41
379
  },
42
380
  } satisfies Meta<typeof Button>;
@@ -44,126 +382,1083 @@ const meta = {
44
382
  export default meta;
45
383
  type Story = StoryObj<typeof meta>;
46
384
 
47
- // Primary button
48
- export const Primary: Story = {
49
- args: {
50
- variant: 'primary',
51
- children: 'Button Text',
52
- size: 'M',
385
+ // ---------------------------------------------------------------------------
386
+ // Helper: attach a per-story description to docs
387
+ // ---------------------------------------------------------------------------
388
+ const withDescription = (story: Story, description: string): Story => ({
389
+ ...story,
390
+ parameters: {
391
+ ...story.parameters,
392
+ docs: { ...story.parameters?.docs, description: { story: description } },
53
393
  },
394
+ });
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Stateful template components
398
+ // Named components avoid hooks-in-callbacks lint issues (react-hooks plugin not
399
+ // configured — do NOT add eslint-disable comments for it).
400
+ // ---------------------------------------------------------------------------
401
+
402
+ const ClickTrackingTemplate = () => {
403
+ const [count, setCount] = useState(0);
404
+ return (
405
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)', alignItems: 'center' }}>
406
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>
407
+ Button clicked
408
+ {' '}
409
+ <strong>{count}</strong>
410
+ {' '}
411
+ {count === 1 ? 'time' : 'times'}
412
+ </p>
413
+ <Button variant="primary" type="button" onClick={() => setCount(c => c + 1)}>
414
+ Save attendance
415
+ </Button>
416
+ </div>
417
+ );
54
418
  };
55
419
 
56
- export const PrimaryIconOnly: Story = {
57
- args: {
58
- ...Primary.args,
59
- children: null,
60
- iconRightName: 'info',
61
- },
420
+ const WithForwardRefTemplate = () => {
421
+ const buttonRef = useRef<HTMLButtonElement>(null);
422
+ return (
423
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)', alignItems: 'center' }}>
424
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>
425
+ Click the trigger to programmatically focus the primary button.
426
+ </p>
427
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)' }}>
428
+ <Button
429
+ variant="secondary"
430
+ type="button"
431
+ onClick={() => buttonRef.current?.focus()}
432
+ >
433
+ Focus primary button
434
+ </Button>
435
+ <Button variant="primary" type="button" ref={buttonRef}>
436
+ Save attendance
437
+ </Button>
438
+ </div>
439
+ </div>
440
+ );
62
441
  };
63
442
 
64
- // Secondary button
65
- export const Secondary: Story = {
66
- args: {
67
- ...Primary.args,
68
- variant: 'secondary',
69
- },
443
+ const FormTypeAttributeTemplate = () => {
444
+ const [submitted, setSubmitted] = useState(false);
445
+ const [cancelled, setCancelled] = useState(false);
446
+ return (
447
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)', minWidth: '340px' }}>
448
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>
449
+ Both buttons live inside a form. The Cancel button uses
450
+ {' '}
451
+ <code>type="button"</code>
452
+ {' '}
453
+ so it does not submit. The Submit button uses
454
+ {' '}
455
+ <code>type="submit"</code>
456
+ .
457
+ </p>
458
+ {submitted && (
459
+ <p style={{ margin: 0, color: 'var(--color-semantic-success-600)' }}>
460
+ Form submitted! (type="submit" fired)
461
+ </p>
462
+ )}
463
+ {cancelled && !submitted && (
464
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>
465
+ Cancelled — form was NOT submitted.
466
+ </p>
467
+ )}
468
+ <form
469
+ onSubmit={(e) => {
470
+ e.preventDefault();
471
+ setSubmitted(true);
472
+ setCancelled(false);
473
+ }}
474
+ style={{ display: 'flex', gap: 'var(--spacing-large)' }}
475
+ >
476
+ <Button
477
+ variant="secondary"
478
+ type="button"
479
+ onClick={() => {
480
+ setSubmitted(false);
481
+ setCancelled(true);
482
+ }}
483
+ >
484
+ Cancel
485
+ </Button>
486
+ <Button variant="primary" type="submit">
487
+ Save attendance
488
+ </Button>
489
+ </form>
490
+ </div>
491
+ );
70
492
  };
71
493
 
72
- export const SecondaryIconOnly: Story = {
73
- args: {
74
- ...Primary.args,
75
- variant: 'secondary',
76
- children: null,
77
- iconRightName: 'info',
78
- },
494
+ const LoadingCompositionTemplate = () => {
495
+ const [loading, setLoading] = useState(false);
496
+ const [done, setDone] = useState(false);
497
+
498
+ const handleClick = () => {
499
+ setLoading(true);
500
+ setDone(false);
501
+ setTimeout(() => {
502
+ setLoading(false);
503
+ setDone(true);
504
+ }, 2000);
505
+ };
506
+
507
+ return (
508
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)', alignItems: 'center' }}>
509
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>
510
+ Button has no built-in loading state — compose it with disabled + a loader icon.
511
+ </p>
512
+ {done && (
513
+ <p style={{ margin: 0, color: 'var(--color-semantic-success-600)' }}>
514
+ Exported successfully!
515
+ </p>
516
+ )}
517
+ <Button
518
+ variant="primary"
519
+ type="button"
520
+ disabled={loading}
521
+ iconLeftName={loading ? 'loader' : undefined}
522
+ iconLeftScreenReaderText={loading ? 'Loading, please wait' : undefined}
523
+ onClick={handleClick}
524
+ >
525
+ {loading ? 'Exporting...' : 'Export to CSV'}
526
+ </Button>
527
+ </div>
528
+ );
79
529
  };
80
530
 
81
- // Tertiary button
82
- export const Tertiary: Story = {
83
- args: {
84
- ...Primary.args,
85
- variant: 'tertiary',
531
+ // ---------------------------------------------------------------------------
532
+ // Stories
533
+ // ---------------------------------------------------------------------------
534
+
535
+ export const Default: Story = withDescription(
536
+ {
537
+ args: {
538
+ variant: 'primary',
539
+ size: 'M',
540
+ children: 'Save attendance',
541
+ disabled: false,
542
+ error: false,
543
+ hasHorizontalPadding: true,
544
+ borderless: false,
545
+ },
546
+ render: args => <Button {...args} />,
86
547
  },
87
- };
548
+ 'The interactive canvas story — every prop is wired to the **Controls** panel. Use the controls to explore variants, sizes, icons, and states. Tip: try setting `iconLeftName` to `"save"` or `"plus"` to see the icon slot in action.',
549
+ );
550
+
551
+ export const Primary: Story = withDescription(
552
+ {
553
+ args: {
554
+ variant: 'primary',
555
+ size: 'M',
556
+ children: 'Save attendance',
557
+ },
558
+ parameters: {
559
+ docs: {
560
+ source: {
561
+ language: 'tsx',
562
+ code: `
563
+ import { Button } from '@arbor-education/design-system.components';
88
564
 
89
- export const TertiaryIconOnly: Story = {
90
- args: {
91
- ...Primary.args,
92
- children: null,
93
- iconRightName: 'info',
94
- variant: 'tertiary',
565
+ function ButtonPrimaryExample() {
566
+ return <Button variant="primary" size="M">Save attendance</Button>;
567
+ }
568
+
569
+ export default ButtonPrimaryExample;
570
+ `.trim(),
571
+ },
572
+ },
573
+ },
95
574
  },
96
- };
575
+ 'The `primary` variant is the main call-to-action. Background resolves to `--button-medium-primary-default-color-background` → `--color-brand-600` (Arbor green). Use at most once per page section. All other actions should be `secondary` or lower in hierarchy.',
576
+ );
577
+
578
+ export const Secondary: Story = withDescription(
579
+ {
580
+ args: {
581
+ variant: 'secondary',
582
+ size: 'M',
583
+ children: 'Cancel',
584
+ },
585
+ parameters: {
586
+ docs: {
587
+ source: {
588
+ language: 'tsx',
589
+ code: `
590
+ import { Button } from '@arbor-education/design-system.components';
591
+
592
+ function ButtonSecondaryExample() {
593
+ return <Button variant="secondary" size="M">Cancel</Button>;
594
+ }
97
595
 
98
- // Dropdown button
99
- export const Dropdown: Story = {
100
- args: {
101
- ...Primary.args,
102
- variant: 'dropdown',
596
+ export default ButtonSecondaryExample;
597
+ `.trim(),
598
+ },
599
+ },
600
+ },
103
601
  },
104
- };
602
+ 'The `secondary` variant is for supporting actions — Cancel, Back, Edit. Outlined with brand-coloured text. Use next to a `primary` button to form a confirm/cancel pair. Never use `text-link` as a Cancel button in a row with Primary.',
603
+ );
604
+
605
+ export const Tertiary: Story = withDescription(
606
+ {
607
+ args: {
608
+ variant: 'tertiary',
609
+ size: 'M',
610
+ children: 'Clear filters',
611
+ },
612
+ parameters: {
613
+ docs: {
614
+ source: {
615
+ language: 'tsx',
616
+ code: `
617
+ import { Button } from '@arbor-education/design-system.components';
618
+
619
+ function ButtonTertiaryExample() {
620
+ return <Button variant="tertiary" size="M">Clear filters</Button>;
621
+ }
105
622
 
106
- // Primary destructive button
107
- export const PrimaryDestructive: Story = {
108
- args: {
109
- ...Primary.args,
110
- variant: 'primary-destructive',
623
+ export default ButtonTertiaryExample;
624
+ `.trim(),
625
+ },
626
+ },
627
+ },
111
628
  },
112
- };
629
+ [
630
+ 'The `tertiary` variant is quiet and de-emphasised — for low-priority utility actions like "Clear filters", "Reset", or optional configuration.',
631
+ '',
632
+ '**Known token note:** the tertiary variant uses `--button-small-tertiary-*` tokens for **both** `M` and `S` sizes.',
633
+ '`--button-medium-tertiary-*` tokens do not exist in `tokens.scss`. The visual result is correct and intentional,',
634
+ 'but the token name reflects "small" regardless of the `size` prop. This is a known gap in the token taxonomy.',
635
+ ].join(' '),
636
+ );
637
+
638
+ export const PrimaryDestructive: Story = withDescription(
639
+ {
640
+ args: {
641
+ variant: 'primary-destructive',
642
+ size: 'M',
643
+ children: 'Delete permanently',
644
+ },
645
+ parameters: {
646
+ docs: {
647
+ source: {
648
+ language: 'tsx',
649
+ code: `
650
+ import { Button } from '@arbor-education/design-system.components';
651
+
652
+ function ButtonPrimaryDestructiveExample() {
653
+ return <Button variant="primary-destructive" size="M">Delete permanently</Button>;
654
+ }
113
655
 
114
- export const PrimaryDestructiveIconOnly: Story = {
115
- args: {
116
- ...Primary.args,
117
- children: null,
118
- iconRightName: 'info',
119
- variant: 'primary-destructive',
656
+ export default ButtonPrimaryDestructiveExample;
657
+ `.trim(),
658
+ },
659
+ },
660
+ },
120
661
  },
121
- };
662
+ 'The `primary-destructive` variant is for irreversible, high-impact actions — permanently deleting a student record, removing an enrolment, expelling a student. Background resolves to `--button-medium-primary-destructive-default-color-background` → `--color-semantic-destructive-500`. Always consider whether a confirmation Modal is warranted before this action executes.',
663
+ );
664
+
665
+ export const SecondaryDestructive: Story = withDescription(
666
+ {
667
+ args: {
668
+ variant: 'secondary-destructive',
669
+ size: 'M',
670
+ children: 'Archive record',
671
+ },
672
+ parameters: {
673
+ docs: {
674
+ source: {
675
+ language: 'tsx',
676
+ code: `
677
+ import { Button } from '@arbor-education/design-system.components';
122
678
 
123
- // Secondary destructive button
124
- export const SecondaryDestructive: Story = {
125
- args: {
126
- ...Primary.args,
127
- variant: 'secondary-destructive',
679
+ function ButtonSecondaryDestructiveExample() {
680
+ return <Button variant="secondary-destructive" size="M">Archive record</Button>;
681
+ }
682
+
683
+ export default ButtonSecondaryDestructiveExample;
684
+ `.trim(),
685
+ },
686
+ },
687
+ },
128
688
  },
129
- };
689
+ 'The `secondary-destructive` variant is for softer destructive actions — archiving, suspending, removing from a group. Default state: red text on white background with red border. On hover: white text on red background (token-driven inversion, intentional). Use when the action is destructive but may have a confirmation step or is partially reversible.',
690
+ );
691
+
692
+ export const TextLink: Story = withDescription(
693
+ {
694
+ args: {
695
+ variant: 'text-link',
696
+ size: 'M',
697
+ children: 'View full report',
698
+ },
699
+ parameters: {
700
+ docs: {
701
+ source: {
702
+ language: 'tsx',
703
+ code: `
704
+ import { Button } from '@arbor-education/design-system.components';
130
705
 
131
- export const SecondaryDestructiveIconOnly: Story = {
132
- args: {
133
- ...Primary.args,
134
- children: null,
135
- iconRightName: 'info',
136
- variant: 'secondary-destructive',
706
+ function ButtonTextLinkExample() {
707
+ return <Button variant="text-link" size="M">View full report</Button>;
708
+ }
709
+
710
+ export default ButtonTextLinkExample;
711
+ `.trim(),
712
+ },
713
+ },
714
+ },
137
715
  },
138
- };
716
+ 'The `text-link` variant renders as underlined text with no box or padding — for inline contextual actions embedded in sentences, labels, or table cells. Do not use `text-link` in a confirm/cancel button row alongside Primary — that breaks affordance hierarchy. For inline placement, combine with `hasHorizontalPadding={false}` to align the text flush with surrounding content.',
717
+ );
139
718
 
140
- // Text link button
141
- export const TextLink: Story = {
142
- args: {
143
- ...Primary.args,
144
- variant: 'text-link',
719
+ export const Dropdown: Story = withDescription(
720
+ {
721
+ args: {
722
+ variant: 'dropdown',
723
+ size: 'M',
724
+ children: 'Select year group',
725
+ },
726
+ parameters: {
727
+ docs: {
728
+ source: {
729
+ language: 'tsx',
730
+ code: `
731
+ import { Button } from '@arbor-education/design-system.components';
732
+
733
+ function ButtonDropdownExample() {
734
+ return (
735
+ <Button variant="dropdown" size="M" iconRightName="chevron-down" iconRightScreenReaderText="Open dropdown">
736
+ Select year group
737
+ </Button>
738
+ );
739
+ }
740
+
741
+ export default ButtonDropdownExample;
742
+ `.trim(),
743
+ },
744
+ },
745
+ },
145
746
  },
146
- };
747
+ [
748
+ 'The `dropdown` variant has `justify-content: space-between` and specific padding designed for use as a',
749
+ 'dropdown trigger — a trailing chevron icon aligns to the right edge automatically. Used internally by',
750
+ '`SelectDropdown`, `ColourPickerDropdown`, and `UserDropdown`, and available to consumers for custom',
751
+ 'dropdown triggers. Pair it with a chevron icon (e.g. `iconRightName="chevron-down"`) for the canonical',
752
+ 'look.',
753
+ ].join(' '),
754
+ );
147
755
 
148
- // Small buttons
149
- export const SmallPrimary: Story = {
150
- args: {
151
- ...Primary.args,
152
- size: 'S',
756
+ export const AllVariants: Story = withDescription(
757
+ {
758
+ render: () => (
759
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xlarge)' }}>
760
+ {/* Group 1: Action hierarchy */}
761
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
762
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>Action hierarchy</p>
763
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', flexWrap: 'wrap' }}>
764
+ <Button variant="primary" type="button">Save attendance</Button>
765
+ <Button variant="secondary" type="button">Cancel</Button>
766
+ <Button variant="tertiary" type="button">Clear filters</Button>
767
+ </div>
768
+ </div>
769
+ {/* Group 2: Destructive actions */}
770
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
771
+ <p style={{ margin: 0, color: 'var(--color-semantic-destructive-600)' }}>Destructive actions</p>
772
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', flexWrap: 'wrap' }}>
773
+ <Button variant="primary-destructive" type="button">Delete permanently</Button>
774
+ <Button variant="secondary-destructive" type="button">Archive record</Button>
775
+ </div>
776
+ </div>
777
+ {/* Group 3: Specialised */}
778
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
779
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>Specialised</p>
780
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', flexWrap: 'wrap' }}>
781
+ <Button variant="text-link" type="button">View full report</Button>
782
+ <Button variant="dropdown" type="button">Select year group</Button>
783
+ </div>
784
+ </div>
785
+ </div>
786
+ ),
787
+ parameters: {
788
+ docs: {
789
+ source: {
790
+ language: 'tsx',
791
+ code: `
792
+ import { Button } from '@arbor-education/design-system.components';
793
+
794
+ function ButtonAllVariantsExample() {
795
+ return (
796
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xlarge)' }}>
797
+ {/* Action hierarchy */}
798
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', flexWrap: 'wrap' }}>
799
+ <Button variant="primary" type="button">Save attendance</Button>
800
+ <Button variant="secondary" type="button">Cancel</Button>
801
+ <Button variant="tertiary" type="button">Clear filters</Button>
802
+ </div>
803
+ {/* Destructive actions */}
804
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', flexWrap: 'wrap' }}>
805
+ <Button variant="primary-destructive" type="button">Delete permanently</Button>
806
+ <Button variant="secondary-destructive" type="button">Archive record</Button>
807
+ </div>
808
+ {/* Specialised */}
809
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', flexWrap: 'wrap' }}>
810
+ <Button variant="text-link" type="button">View full report</Button>
811
+ <Button variant="dropdown" type="button">Select year group</Button>
812
+ </div>
813
+ </div>
814
+ );
815
+ }
816
+
817
+ export default ButtonAllVariantsExample;
818
+ `.trim(),
819
+ },
820
+ },
821
+ },
153
822
  },
154
- };
823
+ 'All 7 variants at a glance, grouped by hierarchy tier. Action hierarchy (primary / secondary / tertiary), Destructive actions (primary-destructive / secondary-destructive), Specialised (text-link / dropdown). Use this story as a reference for the full visual range of the component.',
824
+ );
825
+
826
+ export const AllSizes: Story = withDescription(
827
+ {
828
+ render: () => (
829
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xlarge)' }}>
830
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
831
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>Medium (M) — 2.25rem / 36px via --size-medium</p>
832
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', alignItems: 'center' }}>
833
+ <Button variant="primary" size="M" type="button">Save attendance</Button>
834
+ <Button variant="secondary" size="M" type="button">Cancel</Button>
835
+ <Button variant="tertiary" size="M" type="button">Clear filters</Button>
836
+ </div>
837
+ </div>
838
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
839
+ <p style={{ margin: 0, color: 'var(--color-grey-600)' }}>Small (S) — 2rem / 32px via --size-small</p>
840
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', alignItems: 'center' }}>
841
+ <Button variant="primary" size="S" type="button">Save attendance</Button>
842
+ <Button variant="secondary" size="S" type="button">Cancel</Button>
843
+ <Button variant="tertiary" size="S" type="button">Clear filters</Button>
844
+ </div>
845
+ </div>
846
+ </div>
847
+ ),
848
+ parameters: {
849
+ docs: {
850
+ source: {
851
+ language: 'tsx',
852
+ code: `
853
+ import { Button } from '@arbor-education/design-system.components';
155
854
 
156
- export const SmallSecondary: Story = {
157
- args: {
158
- ...Secondary.args,
159
- size: 'S',
855
+ function ButtonAllSizesExample() {
856
+ return (
857
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xlarge)' }}>
858
+ {/* Medium (M) — 2.25rem / 36px */}
859
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', alignItems: 'center' }}>
860
+ <Button variant="primary" size="M" type="button">Save attendance</Button>
861
+ <Button variant="secondary" size="M" type="button">Cancel</Button>
862
+ <Button variant="tertiary" size="M" type="button">Clear filters</Button>
863
+ </div>
864
+
865
+ {/* Small (S) — 2rem / 32px */}
866
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)', alignItems: 'center' }}>
867
+ <Button variant="primary" size="S" type="button">Save attendance</Button>
868
+ <Button variant="secondary" size="S" type="button">Cancel</Button>
869
+ <Button variant="tertiary" size="S" type="button">Clear filters</Button>
870
+ </div>
871
+ </div>
872
+ );
873
+ }
874
+
875
+ export default ButtonAllSizesExample;
876
+ `.trim(),
877
+ },
878
+ },
879
+ },
160
880
  },
161
- };
881
+ 'Both sizes side by side. `M` resolves to `--size-medium` (2.25rem, 36px). `S` resolves to `--size-small` (2rem, 32px). Heights are entirely token-driven — never hardcode a height on a button wrapper.',
882
+ );
883
+
884
+ export const SmallPrimary: Story = withDescription(
885
+ {
886
+ args: {
887
+ variant: 'primary',
888
+ size: 'S',
889
+ children: 'Save attendance',
890
+ },
891
+ parameters: {
892
+ docs: {
893
+ source: {
894
+ language: 'tsx',
895
+ code: `
896
+ import { Button } from '@arbor-education/design-system.components';
897
+
898
+ function ButtonSmallPrimaryExample() {
899
+ return <Button variant="primary" size="S">Save attendance</Button>;
900
+ }
162
901
 
163
- // Disabled button
164
- export const Disabled: Story = {
165
- args: {
166
- ...Primary.args,
167
- disabled: true,
902
+ export default ButtonSmallPrimaryExample;
903
+ `.trim(),
904
+ },
905
+ },
906
+ },
168
907
  },
169
- };
908
+ 'Small primary button — 2rem / 32px height via `--size-small`. Use in dense toolbars or compact table headers where the medium size would feel too tall.',
909
+ );
910
+
911
+ export const SmallSecondary: Story = withDescription(
912
+ {
913
+ args: {
914
+ variant: 'secondary',
915
+ size: 'S',
916
+ children: 'Cancel',
917
+ },
918
+ parameters: {
919
+ docs: {
920
+ source: {
921
+ language: 'tsx',
922
+ code: `
923
+ import { Button } from '@arbor-education/design-system.components';
924
+
925
+ function ButtonSmallSecondaryExample() {
926
+ return <Button variant="secondary" size="S">Cancel</Button>;
927
+ }
928
+
929
+ export default ButtonSmallSecondaryExample;
930
+ `.trim(),
931
+ },
932
+ },
933
+ },
934
+ },
935
+ 'Small secondary button. Matches the small primary in height for pairing in compact confirm/cancel rows.',
936
+ );
937
+
938
+ export const WithIconLeft: Story = withDescription(
939
+ {
940
+ args: {
941
+ variant: 'primary',
942
+ size: 'M',
943
+ children: 'Export to CSV',
944
+ iconLeftName: 'download',
945
+ iconLeftScreenReaderText: 'Download',
946
+ },
947
+ parameters: {
948
+ docs: {
949
+ source: {
950
+ language: 'tsx',
951
+ code: `
952
+ import { Button } from '@arbor-education/design-system.components';
953
+
954
+ function ButtonWithIconLeftExample() {
955
+ return (
956
+ <Button variant="primary" size="M" iconLeftName="download" iconLeftScreenReaderText="Download">
957
+ Export to CSV
958
+ </Button>
959
+ );
960
+ }
961
+
962
+ export default ButtonWithIconLeftExample;
963
+ `.trim(),
964
+ },
965
+ },
966
+ },
967
+ },
968
+ 'Icon on the left of the label via `iconLeftName`. The icon renders at 16px. `iconLeftScreenReaderText` overrides the accessible label for the icon — here it is set to `"Download"` which is more natural than the icon name `"download"` (though similar in this case). Use left icons for actions where the icon reinforces the verb: download, add, send.',
969
+ );
970
+
971
+ export const WithIconRight: Story = withDescription(
972
+ {
973
+ args: {
974
+ variant: 'secondary',
975
+ size: 'M',
976
+ children: 'Send to parents',
977
+ iconRightName: 'send',
978
+ iconRightScreenReaderText: 'Send',
979
+ },
980
+ parameters: {
981
+ docs: {
982
+ source: {
983
+ language: 'tsx',
984
+ code: `
985
+ import { Button } from '@arbor-education/design-system.components';
986
+
987
+ function ButtonWithIconRightExample() {
988
+ return (
989
+ <Button variant="secondary" size="M" iconRightName="send" iconRightScreenReaderText="Send">
990
+ Send to parents
991
+ </Button>
992
+ );
993
+ }
994
+
995
+ export default ButtonWithIconRightExample;
996
+ `.trim(),
997
+ },
998
+ },
999
+ },
1000
+ },
1001
+ 'Icon on the right of the label via `iconRightName`. Right icons typically indicate direction or outcome: `arrow-right` for "next", `external-link` for "opens in new tab", `chevron-down` for "opens a menu". Pair right icons with the `dropdown` variant for SelectDropdown triggers.',
1002
+ );
1003
+
1004
+ export const WithBothIcons: Story = withDescription(
1005
+ {
1006
+ args: {
1007
+ variant: 'primary',
1008
+ size: 'M',
1009
+ children: 'Generate report',
1010
+ iconLeftName: 'sparkles',
1011
+ iconLeftScreenReaderText: 'AI generated',
1012
+ iconRightName: 'arrow-right',
1013
+ iconRightScreenReaderText: 'Continue',
1014
+ },
1015
+ parameters: {
1016
+ docs: {
1017
+ source: {
1018
+ language: 'tsx',
1019
+ code: `
1020
+ import { Button } from '@arbor-education/design-system.components';
1021
+
1022
+ function ButtonWithBothIconsExample() {
1023
+ return (
1024
+ <Button
1025
+ variant="primary"
1026
+ size="M"
1027
+ iconLeftName="sparkles"
1028
+ iconLeftScreenReaderText="AI generated"
1029
+ iconRightName="arrow-right"
1030
+ iconRightScreenReaderText="Continue"
1031
+ >
1032
+ Generate report
1033
+ </Button>
1034
+ );
1035
+ }
1036
+
1037
+ export default ButtonWithBothIconsExample;
1038
+ `.trim(),
1039
+ },
1040
+ },
1041
+ },
1042
+ },
1043
+ 'Both icon slots used simultaneously. Left icon reinforces the action type (`sparkles` for AI-generated), right icon indicates direction or outcome (`arrow-right` for proceed). The dual-icon pattern is rare — only use it when both icons add distinct meaning. The label should be short enough that the button does not become unwieldy.',
1044
+ );
1045
+
1046
+ export const IconOnly: Story = withDescription(
1047
+ {
1048
+ args: {
1049
+ variant: 'secondary',
1050
+ size: 'M',
1051
+ iconRightName: '3-dot',
1052
+ iconRightScreenReaderText: 'More actions',
1053
+ },
1054
+ parameters: {
1055
+ docs: {
1056
+ source: {
1057
+ language: 'tsx',
1058
+ code: `
1059
+ import { Button } from '@arbor-education/design-system.components';
1060
+
1061
+ function ButtonIconOnlyExample() {
1062
+ return <Button variant="secondary" size="M" iconRightName="3-dot" iconRightScreenReaderText="More actions" />;
1063
+ }
1064
+
1065
+ export default ButtonIconOnlyExample;
1066
+ `.trim(),
1067
+ },
1068
+ },
1069
+ },
1070
+ },
1071
+ 'Icon-only mode is triggered automatically when `children` is omitted and at least one icon prop is provided. The component adds `ds-button--icon-only` for square sizing. **Always** provide `iconRightScreenReaderText` (or `iconLeftScreenReaderText`) — the icon name `"3-dot"` is meaningless to a screen reader. This story uses `"More actions"` which clearly communicates the button\'s purpose.',
1072
+ );
1073
+
1074
+ export const IconOnlyAccessibility: Story = withDescription(
1075
+ {
1076
+ render: () => (
1077
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xlarge)' }}>
1078
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1079
+ <p style={{ margin: 0, color: 'var(--color-semantic-destructive-600)' }}>
1080
+ Bad — screen reader announces "3-dot" or "x" (the raw icon name)
1081
+ </p>
1082
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)' }}>
1083
+ {/* No screenReaderText override — falls back to icon name */}
1084
+ <Button variant="secondary" type="button" iconRightName="3-dot" />
1085
+ <Button variant="secondary" type="button" iconRightName="x" />
1086
+ <Button variant="secondary" type="button" iconRightName="pencil" />
1087
+ </div>
1088
+ </div>
1089
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1090
+ <p style={{ margin: 0, color: 'var(--color-semantic-success-600)' }}>
1091
+ Good — screen reader announces a human-readable action name
1092
+ </p>
1093
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)' }}>
1094
+ <Button variant="secondary" type="button" iconRightName="3-dot" iconRightScreenReaderText="More actions" />
1095
+ <Button variant="secondary" type="button" iconRightName="x" iconRightScreenReaderText="Dismiss" />
1096
+ <Button variant="secondary" type="button" iconRightName="pencil" iconRightScreenReaderText="Edit student details" />
1097
+ </div>
1098
+ </div>
1099
+ </div>
1100
+ ),
1101
+ parameters: {
1102
+ docs: {
1103
+ source: {
1104
+ language: 'tsx',
1105
+ code: `
1106
+ import { Button } from '@arbor-education/design-system.components';
1107
+
1108
+ function ButtonIconOnlyAccessibilityExample() {
1109
+ return (
1110
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1111
+ {/* Bad — screen reader announces raw icon names */}
1112
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)' }}>
1113
+ <Button variant="secondary" type="button" iconRightName="3-dot" />
1114
+ <Button variant="secondary" type="button" iconRightName="x" />
1115
+ <Button variant="secondary" type="button" iconRightName="pencil" />
1116
+ </div>
1117
+
1118
+ {/* Good — human-readable action names */}
1119
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)' }}>
1120
+ <Button variant="secondary" type="button" iconRightName="3-dot" iconRightScreenReaderText="More actions" />
1121
+ <Button variant="secondary" type="button" iconRightName="x" iconRightScreenReaderText="Dismiss" />
1122
+ <Button variant="secondary" type="button" iconRightName="pencil" iconRightScreenReaderText="Edit student details" />
1123
+ </div>
1124
+ </div>
1125
+ );
1126
+ }
1127
+
1128
+ export default ButtonIconOnlyAccessibilityExample;
1129
+ `.trim(),
1130
+ },
1131
+ },
1132
+ },
1133
+ },
1134
+ 'Icon-only accessibility contrast. The top row has no `iconRightScreenReaderText` override — screen readers announce the raw icon names (`"3-dot"`, `"x"`, `"pencil"`). The bottom row overrides with human-readable action names. Always provide `iconLeftScreenReaderText` or `iconRightScreenReaderText` when using icon-only buttons — the icon name is almost never descriptive enough in context.',
1135
+ );
1136
+
1137
+ export const Disabled: Story = withDescription(
1138
+ {
1139
+ args: {
1140
+ variant: 'primary',
1141
+ size: 'M',
1142
+ children: 'Save attendance',
1143
+ disabled: true,
1144
+ },
1145
+ parameters: {
1146
+ docs: {
1147
+ source: {
1148
+ language: 'tsx',
1149
+ code: `
1150
+ import { Button } from '@arbor-education/design-system.components';
1151
+
1152
+ function ButtonDisabledExample() {
1153
+ return <Button variant="primary" size="M" disabled>Save attendance</Button>;
1154
+ }
1155
+
1156
+ export default ButtonDisabledExample;
1157
+ `.trim(),
1158
+ },
1159
+ },
1160
+ },
1161
+ },
1162
+ 'Native HTML `disabled` attribute. Applies `opacity: 0.5`, `cursor: not-allowed`, and `pointer-events: none`. Removes the button from tab order. Do NOT simulate disabled with manual opacity or CSS — use this prop so the browser and assistive technologies also receive the disabled signal.',
1163
+ );
1164
+
1165
+ export const ErrorState: Story = withDescription(
1166
+ {
1167
+ args: {
1168
+ variant: 'dropdown',
1169
+ size: 'M',
1170
+ children: 'Select year group',
1171
+ error: true,
1172
+ },
1173
+ parameters: {
1174
+ docs: {
1175
+ source: {
1176
+ language: 'tsx',
1177
+ code: `
1178
+ import { Button } from '@arbor-education/design-system.components';
1179
+
1180
+ function ButtonErrorStateExample() {
1181
+ return <Button variant="dropdown" size="M" error>Select year group</Button>;
1182
+ }
1183
+
1184
+ export default ButtonErrorStateExample;
1185
+ `.trim(),
1186
+ },
1187
+ },
1188
+ },
1189
+ },
1190
+ 'The `error` prop adds a red border (`--color-semantic-destructive-500`) via `ds-button--error`. This is used by `SelectDropdown` and other form-field trigger buttons to indicate validation failure on the field. It is **purely visual** — no `aria-invalid` is set on the button itself. Consumers are responsible for communicating the error state to assistive tech (e.g. `aria-invalid` on the associated input).',
1191
+ );
1192
+
1193
+ export const Borderless: Story = withDescription(
1194
+ {
1195
+ args: {
1196
+ variant: 'tertiary',
1197
+ size: 'M',
1198
+ children: 'Settings',
1199
+ iconLeftName: 'settings',
1200
+ iconLeftScreenReaderText: 'Settings',
1201
+ borderless: true,
1202
+ },
1203
+ parameters: {
1204
+ docs: {
1205
+ source: {
1206
+ language: 'tsx',
1207
+ code: `
1208
+ import { Button } from '@arbor-education/design-system.components';
1209
+
1210
+ function ButtonBorderlessExample() {
1211
+ return (
1212
+ <Button variant="tertiary" size="M" iconLeftName="settings" iconLeftScreenReaderText="Settings" borderless>
1213
+ Settings
1214
+ </Button>
1215
+ );
1216
+ }
1217
+
1218
+ export default ButtonBorderlessExample;
1219
+ `.trim(),
1220
+ },
1221
+ },
1222
+ },
1223
+ },
1224
+ 'The `borderless` prop removes the border entirely via `ds-button--borderless`. Useful in toolbar contexts where a visible border would add visual noise — for example, icon-adjacent utility buttons in a rich text editor toolbar or a data grid header. Pairs naturally with `variant="tertiary"` and an icon.',
1225
+ );
1226
+
1227
+ export const NoHorizontalPadding: Story = withDescription(
1228
+ {
1229
+ args: {
1230
+ variant: 'text-link',
1231
+ size: 'M',
1232
+ children: 'View full attendance report',
1233
+ hasHorizontalPadding: false,
1234
+ },
1235
+ parameters: {
1236
+ docs: {
1237
+ source: {
1238
+ language: 'tsx',
1239
+ code: `
1240
+ import { Button } from '@arbor-education/design-system.components';
1241
+
1242
+ function ButtonNoHorizontalPaddingExample() {
1243
+ return (
1244
+ <Button variant="text-link" size="M" hasHorizontalPadding={false}>
1245
+ View full attendance report
1246
+ </Button>
1247
+ );
1248
+ }
1249
+
1250
+ export default ButtonNoHorizontalPaddingExample;
1251
+ `.trim(),
1252
+ },
1253
+ },
1254
+ },
1255
+ },
1256
+ '`hasHorizontalPadding={false}` removes horizontal padding via `ds-button--no-horizontal-padding`. The canonical use case is a `text-link` variant sitting inline in a form label or table cell, where the default padding would indent the text away from the surrounding content. Do not use this as a general layout escape hatch — it is specifically for flush-mounted inline actions.',
1257
+ );
1258
+
1259
+ export const FormTypeAttribute: Story = withDescription(
1260
+ {
1261
+ render: FormTypeAttributeTemplate,
1262
+ parameters: {
1263
+ docs: {
1264
+ source: {
1265
+ language: 'tsx',
1266
+ code: `
1267
+ import { useState } from 'react';
1268
+ import { Button } from '@arbor-education/design-system.components';
1269
+
1270
+ function FormTypeAttributeExample() {
1271
+ const [submitted, setSubmitted] = useState(false);
1272
+ const [cancelled, setCancelled] = useState(false);
1273
+ return (
1274
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)', minWidth: '340px' }}>
1275
+ {submitted && (
1276
+ <p style={{ color: 'var(--color-semantic-success-600)' }}>
1277
+ Form submitted! (type="submit" fired)
1278
+ </p>
1279
+ )}
1280
+ {cancelled && !submitted && (
1281
+ <p style={{ color: 'var(--color-grey-600)' }}>
1282
+ Cancelled — form was NOT submitted.
1283
+ </p>
1284
+ )}
1285
+ <form
1286
+ onSubmit={(e) => {
1287
+ e.preventDefault();
1288
+ setSubmitted(true);
1289
+ setCancelled(false);
1290
+ }}
1291
+ style={{ display: 'flex', gap: 'var(--spacing-large)' }}
1292
+ >
1293
+ <Button
1294
+ variant="secondary"
1295
+ type="button"
1296
+ onClick={() => {
1297
+ setSubmitted(false);
1298
+ setCancelled(true);
1299
+ }}
1300
+ >
1301
+ Cancel
1302
+ </Button>
1303
+ <Button variant="primary" type="submit">
1304
+ Save attendance
1305
+ </Button>
1306
+ </form>
1307
+ </div>
1308
+ );
1309
+ }
1310
+
1311
+ export default FormTypeAttributeExample;
1312
+ `.trim(),
1313
+ },
1314
+ },
1315
+ },
1316
+ },
1317
+ [
1318
+ '**Critical gotcha demo.** The `type` prop is explicitly omitted from `ButtonProps` via',
1319
+ '`Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type" | "onClick">`. This means `<Button>` never',
1320
+ 'sets a `type` attribute, and browsers default to `type="submit"`. Any `<Button>` inside a `<form>`',
1321
+ 'will submit that form on click unless you pass `type="button"` explicitly.',
1322
+ '',
1323
+ 'This story shows a form with both patterns side by side. The "Cancel" button uses `type="button"`',
1324
+ 'so clicking it does not submit. The "Save attendance" button uses `type="submit"` to intentionally',
1325
+ 'submit. Click both and watch the status message.',
1326
+ ].join(' '),
1327
+ );
1328
+
1329
+ export const LoadingComposition: Story = withDescription(
1330
+ {
1331
+ render: LoadingCompositionTemplate,
1332
+ parameters: {
1333
+ docs: {
1334
+ source: {
1335
+ language: 'tsx',
1336
+ code: `
1337
+ import { useState } from 'react';
1338
+ import { Button } from '@arbor-education/design-system.components';
1339
+
1340
+ function LoadingCompositionExample() {
1341
+ const [loading, setLoading] = useState(false);
1342
+ const [done, setDone] = useState(false);
1343
+
1344
+ const handleClick = () => {
1345
+ setLoading(true);
1346
+ setDone(false);
1347
+ setTimeout(() => {
1348
+ setLoading(false);
1349
+ setDone(true);
1350
+ }, 2000);
1351
+ };
1352
+
1353
+ return (
1354
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)', alignItems: 'center' }}>
1355
+ {done && (
1356
+ <p style={{ color: 'var(--color-semantic-success-600)' }}>Exported successfully!</p>
1357
+ )}
1358
+ <Button
1359
+ variant="primary"
1360
+ type="button"
1361
+ disabled={loading}
1362
+ iconLeftName={loading ? 'loader' : undefined}
1363
+ iconLeftScreenReaderText={loading ? 'Loading, please wait' : undefined}
1364
+ onClick={handleClick}
1365
+ >
1366
+ {loading ? 'Exporting...' : 'Export to CSV'}
1367
+ </Button>
1368
+ </div>
1369
+ );
1370
+ }
1371
+
1372
+ export default LoadingCompositionExample;
1373
+ `.trim(),
1374
+ },
1375
+ },
1376
+ },
1377
+ },
1378
+ [
1379
+ 'Button has **no built-in loading state** — compose one with `disabled`, `iconLeftName`, and a label change.',
1380
+ 'This pattern uses `disabled={loading}` to block interaction during the async operation, `iconLeftName="loader"`',
1381
+ 'to show a spinner icon, and `iconLeftScreenReaderText="Loading, please wait"` so screen reader users are',
1382
+ 'informed. After 2 seconds the mock export resolves and the button returns to its default state.',
1383
+ '',
1384
+ 'The `loader` icon (`LoaderCircle` from Lucide) does not spin by default — animate it with a CSS',
1385
+ '`animation: spin 1s linear infinite` keyframe on the `svg` within the button if your design requires it.',
1386
+ ].join(' '),
1387
+ );
1388
+
1389
+ export const WithForwardRef: Story = withDescription(
1390
+ {
1391
+ render: WithForwardRefTemplate,
1392
+ parameters: {
1393
+ docs: {
1394
+ source: {
1395
+ language: 'tsx',
1396
+ code: `
1397
+ import { useRef } from 'react';
1398
+ import { Button } from '@arbor-education/design-system.components';
1399
+
1400
+ function WithForwardRefExample() {
1401
+ const buttonRef = useRef<HTMLButtonElement>(null);
1402
+ return (
1403
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)', alignItems: 'center' }}>
1404
+ <p style={{ color: 'var(--color-grey-600)' }}>
1405
+ Click the trigger to programmatically focus the primary button.
1406
+ </p>
1407
+ <div style={{ display: 'flex', gap: 'var(--spacing-large)' }}>
1408
+ <Button
1409
+ variant="secondary"
1410
+ type="button"
1411
+ onClick={() => buttonRef.current?.focus()}
1412
+ >
1413
+ Focus primary button
1414
+ </Button>
1415
+ <Button variant="primary" type="button" ref={buttonRef}>
1416
+ Save attendance
1417
+ </Button>
1418
+ </div>
1419
+ </div>
1420
+ );
1421
+ }
1422
+
1423
+ export default WithForwardRefExample;
1424
+ `.trim(),
1425
+ },
1426
+ },
1427
+ },
1428
+ },
1429
+ '`Button` uses `forwardRef` — it exposes the underlying `<button>` DOM node via `ref`. This story demonstrates programmatic focus: clicking "Focus primary button" calls `buttonRef.current?.focus()` on the second button. Common real-world uses: restoring focus after a modal closes, advancing focus in a multi-step form, or triggering a button from a keyboard shortcut handler.',
1430
+ );
1431
+
1432
+ export const ClickTracking: Story = withDescription(
1433
+ {
1434
+ render: ClickTrackingTemplate,
1435
+ parameters: {
1436
+ docs: {
1437
+ source: {
1438
+ language: 'tsx',
1439
+ code: `
1440
+ import { useState } from 'react';
1441
+ import { Button } from '@arbor-education/design-system.components';
1442
+
1443
+ function ClickTrackingExample() {
1444
+ const [count, setCount] = useState(0);
1445
+ return (
1446
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)', alignItems: 'center' }}>
1447
+ <p style={{ color: 'var(--color-grey-600)' }}>
1448
+ Button clicked <strong>{count}</strong> {count === 1 ? 'time' : 'times'}
1449
+ </p>
1450
+ <Button variant="primary" type="button" onClick={() => setCount(c => c + 1)}>
1451
+ Save attendance
1452
+ </Button>
1453
+ </div>
1454
+ );
1455
+ }
1456
+
1457
+ export default ClickTrackingExample;
1458
+ `.trim(),
1459
+ },
1460
+ },
1461
+ },
1462
+ },
1463
+ 'Demonstrates that `onClick` fires correctly with each button activation (click, Space, Enter). The counter increments on every activation. Note `type="button"` — this story\'s button is not inside a form but it is good practice to always be explicit about `type` to avoid accidental form submissions.',
1464
+ );