@arbor-education/design-system.components 1.0.5 → 2.0.0

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 (116) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/components/breadcrumbs/BreadcrumbCopyButton.d.ts.map +1 -1
  3. package/dist/components/breadcrumbs/BreadcrumbCopyButton.js +3 -2
  4. package/dist/components/breadcrumbs/BreadcrumbCopyButton.js.map +1 -1
  5. package/dist/components/breadcrumbs/BreadcrumbEllipsis.d.ts.map +1 -1
  6. package/dist/components/breadcrumbs/BreadcrumbEllipsis.js +2 -1
  7. package/dist/components/breadcrumbs/BreadcrumbEllipsis.js.map +1 -1
  8. package/dist/components/button/Button.d.ts +17 -8
  9. package/dist/components/button/Button.d.ts.map +1 -1
  10. package/dist/components/button/Button.js +5 -6
  11. package/dist/components/button/Button.js.map +1 -1
  12. package/dist/components/button/Button.stories.d.ts +17 -36
  13. package/dist/components/button/Button.stories.d.ts.map +1 -1
  14. package/dist/components/button/Button.stories.js +165 -214
  15. package/dist/components/button/Button.stories.js.map +1 -1
  16. package/dist/components/button/Button.test.d.ts.map +1 -1
  17. package/dist/components/button/Button.test.js +16 -10
  18. package/dist/components/button/Button.test.js.map +1 -1
  19. package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
  20. package/dist/components/datePicker/DatePicker.js +2 -1
  21. package/dist/components/datePicker/DatePicker.js.map +1 -1
  22. package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -1
  23. package/dist/components/dateTimePicker/DateTimePicker.js +2 -1
  24. package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -1
  25. package/dist/components/dropdown/Dropdown.stories.d.ts.map +1 -1
  26. package/dist/components/dropdown/Dropdown.stories.js +5 -3
  27. package/dist/components/dropdown/Dropdown.stories.js.map +1 -1
  28. package/dist/components/emptyState/EmptyState.stories.d.ts.map +1 -1
  29. package/dist/components/emptyState/EmptyState.stories.js +3 -2
  30. package/dist/components/emptyState/EmptyState.stories.js.map +1 -1
  31. package/dist/components/fileUpload/FileUpload.d.ts.map +1 -1
  32. package/dist/components/fileUpload/FileUpload.js +1 -1
  33. package/dist/components/fileUpload/FileUpload.js.map +1 -1
  34. package/dist/components/fileUpload/FileUpload.test.js +4 -4
  35. package/dist/components/fileUpload/FileUpload.test.js.map +1 -1
  36. package/dist/components/formField/inputs/colourPickerDropdown/ColourPickerDropdown.d.ts.map +1 -1
  37. package/dist/components/formField/inputs/colourPickerDropdown/ColourPickerDropdown.js +2 -1
  38. package/dist/components/formField/inputs/colourPickerDropdown/ColourPickerDropdown.js.map +1 -1
  39. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.d.ts.map +1 -1
  40. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js +2 -1
  41. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js.map +1 -1
  42. package/dist/components/grid/Grid.stories.d.ts.map +1 -1
  43. package/dist/components/grid/Grid.stories.js +4 -3
  44. package/dist/components/grid/Grid.stories.js.map +1 -1
  45. package/dist/components/heading/Heading.stories.d.ts.map +1 -1
  46. package/dist/components/heading/Heading.stories.js +18 -14
  47. package/dist/components/heading/Heading.stories.js.map +1 -1
  48. package/dist/components/icon/Icon.stories.d.ts.map +1 -1
  49. package/dist/components/icon/Icon.stories.js +30 -31
  50. package/dist/components/icon/Icon.stories.js.map +1 -1
  51. package/dist/components/modal/ModalCloseButton.d.ts.map +1 -1
  52. package/dist/components/modal/ModalCloseButton.js +2 -1
  53. package/dist/components/modal/ModalCloseButton.js.map +1 -1
  54. package/dist/components/section/Section.stories.d.ts.map +1 -1
  55. package/dist/components/section/Section.stories.js +52 -25
  56. package/dist/components/section/Section.stories.js.map +1 -1
  57. package/dist/components/table/Table.stories.d.ts.map +1 -1
  58. package/dist/components/table/Table.stories.js +4 -6
  59. package/dist/components/table/Table.stories.js.map +1 -1
  60. package/dist/components/table/pagination/PageSizeSelector.d.ts.map +1 -1
  61. package/dist/components/table/pagination/PageSizeSelector.js +2 -1
  62. package/dist/components/table/pagination/PageSizeSelector.js.map +1 -1
  63. package/dist/components/table/pagination/PaginationControls.d.ts.map +1 -1
  64. package/dist/components/table/pagination/PaginationControls.js +3 -2
  65. package/dist/components/table/pagination/PaginationControls.js.map +1 -1
  66. package/dist/components/table/tableControls/BulkActionsDropdown.d.ts.map +1 -1
  67. package/dist/components/table/tableControls/BulkActionsDropdown.js +3 -2
  68. package/dist/components/table/tableControls/BulkActionsDropdown.js.map +1 -1
  69. package/dist/components/table/tableControls/TableControls.d.ts.map +1 -1
  70. package/dist/components/table/tableControls/TableControls.js +2 -1
  71. package/dist/components/table/tableControls/TableControls.js.map +1 -1
  72. package/dist/components/table/tableControls/TableSettingsDropdown.d.ts.map +1 -1
  73. package/dist/components/table/tableControls/TableSettingsDropdown.js +2 -1
  74. package/dist/components/table/tableControls/TableSettingsDropdown.js.map +1 -1
  75. package/dist/components/toast/Toast.js +1 -1
  76. package/dist/components/toast/Toast.js.map +1 -1
  77. package/dist/components/tooltip/Tooltip.stories.js +5 -5
  78. package/dist/components/tooltip/Tooltip.stories.js.map +1 -1
  79. package/dist/components/tooltip/TooltipWrapper.stories.js +2 -2
  80. package/dist/components/tooltip/TooltipWrapper.stories.js.map +1 -1
  81. package/dist/components/userDropdown/internal/UserDropdownTrigger.d.ts.map +1 -1
  82. package/dist/components/userDropdown/internal/UserDropdownTrigger.js +3 -2
  83. package/dist/components/userDropdown/internal/UserDropdownTrigger.js.map +1 -1
  84. package/dist/index.css +15 -15
  85. package/dist/index.css.map +1 -1
  86. package/package.json +1 -1
  87. package/src/components/breadcrumbs/BreadcrumbCopyButton.tsx +5 -2
  88. package/src/components/breadcrumbs/BreadcrumbEllipsis.tsx +5 -2
  89. package/src/components/breadcrumbs/breadcrumbs.scss +2 -2
  90. package/src/components/button/Button.stories.tsx +205 -230
  91. package/src/components/button/Button.test.tsx +36 -9
  92. package/src/components/button/Button.tsx +6 -16
  93. package/src/components/button/button.scss +8 -8
  94. package/src/components/datePicker/DatePicker.tsx +5 -3
  95. package/src/components/dateTimePicker/DateTimePicker.tsx +5 -3
  96. package/src/components/dropdown/Dropdown.stories.tsx +7 -3
  97. package/src/components/emptyState/EmptyState.stories.tsx +4 -2
  98. package/src/components/fileUpload/FileUpload.test.tsx +4 -4
  99. package/src/components/fileUpload/FileUpload.tsx +8 -4
  100. package/src/components/formField/inputs/colourPickerDropdown/ColourPickerDropdown.tsx +3 -1
  101. package/src/components/formField/inputs/selectDropdown/SelectDropdown.tsx +3 -2
  102. package/src/components/grid/Grid.stories.tsx +17 -4
  103. package/src/components/heading/Heading.stories.tsx +23 -16
  104. package/src/components/icon/Icon.stories.tsx +43 -44
  105. package/src/components/modal/ModalCloseButton.tsx +5 -3
  106. package/src/components/section/Section.stories.tsx +86 -31
  107. package/src/components/table/Table.stories.tsx +19 -6
  108. package/src/components/table/pagination/PageSizeSelector.tsx +3 -1
  109. package/src/components/table/pagination/PaginationControls.tsx +17 -12
  110. package/src/components/table/tableControls/BulkActionsDropdown.tsx +3 -1
  111. package/src/components/table/tableControls/TableControls.tsx +31 -22
  112. package/src/components/table/tableControls/TableSettingsDropdown.tsx +7 -4
  113. package/src/components/toast/Toast.tsx +1 -1
  114. package/src/components/tooltip/Tooltip.stories.tsx +7 -7
  115. package/src/components/tooltip/TooltipWrapper.stories.tsx +4 -4
  116. package/src/components/userDropdown/internal/UserDropdownTrigger.tsx +2 -1
@@ -10,6 +10,7 @@ import {
10
10
  } from '@storybook/addon-docs/blocks';
11
11
  import { useState, useRef } from 'react';
12
12
  import { Button } from './Button.js';
13
+ import { Icon } from 'Components/icon/Icon';
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // Component description — built as a joined array to avoid no-useless-escape
@@ -23,8 +24,9 @@ import { Button } from './Button.js';
23
24
 
24
25
  const DESCRIPTION_INTRO = [
25
26
  '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.',
27
+ '`<button>` element with `forwardRef` support, consistent design tokens, and a full set',
28
+ 'of variant and state modifiers. Icons are composed via `children` using the `Icon` component,',
29
+ 'so consumers control icon placement, colour, and accessible text directly.',
28
30
  ].join('\n');
29
31
 
30
32
  const USAGE_GUIDANCE = [
@@ -69,9 +71,10 @@ const DEVELOPER_NOTES = [
69
71
  '### Critical gotchas',
70
72
  '',
71
73
  '#### 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',
74
+ '`type` is a fully typed prop (`\'button\' | \'submit\' | \'reset\'`, inherited from',
75
+ '`ButtonHTMLAttributes<HTMLButtonElement>`), but the component **does not default it**',
76
+ '— there is no `type = \'button\'` fallback in `Button.tsx`. This means that if you omit it,',
77
+ 'the underlying `<button>` has no `type` attribute, and browsers default to',
75
78
  '`type="submit"`. If this button lives inside a `<form>`, clicking it **will submit the form**',
76
79
  'unless you explicitly pass `type="button"`.',
77
80
  '',
@@ -83,17 +86,22 @@ const DEVELOPER_NOTES = [
83
86
  '<Button variant="secondary" type="button" onClick={handleCancel}>Cancel</Button>',
84
87
  '```',
85
88
  '',
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`.',
89
+ '#### 2. Icons are composed via `children`',
90
+ 'Icons are no longer separate props — render an `Icon` as part of `children`, placed before and/or',
91
+ 'after the label text. Set the explicit `isRound` prop for square, no-padding sizing when there is',
92
+ 'no visible label. Because the icon is just a child element, always pass `screenReaderText` on the',
93
+ '`Icon` yourself when it is the button\'s only accessible content — icon names like `"3-dot"` or',
94
+ '`"x"` are not meaningful in context. When an icon sits alongside a visible label (e.g. a trailing',
95
+ 'chevron), omit `screenReaderText` so it stays decorative and does not duplicate the label.',
90
96
  '',
91
97
  '```tsx',
92
- '// BAD — screen reader says "3-dot"',
93
- '<Button variant="secondary" iconRightName="3-dot" />',
98
+ '// BAD — no accessible name at all',
99
+ '<Button variant="secondary" isRound><Icon name="3-dot" /></Button>',
94
100
  '',
95
101
  '// GOOD',
96
- '<Button variant="secondary" iconRightName="3-dot" iconRightScreenReaderText="More actions" />',
102
+ '<Button variant="secondary" isRound>',
103
+ ' <Icon name="3-dot" screenReaderText="More actions" />',
104
+ '</Button>',
97
105
  '```',
98
106
  '',
99
107
  '---',
@@ -103,10 +111,32 @@ const DEVELOPER_NOTES = [
103
111
  '- Native `<button>` — focusable with Tab, activated with Space or Enter',
104
112
  '- Focus ring: 3px outline using `--focus-border` and `--color-brand-500` via `--focus-color-focus`',
105
113
  '- `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',
114
+ '- Icon-only mode (`isRound`): always pass `screenReaderText` on the child `Icon` — it is the button\'s only accessible label',
107
115
  '- The `error` prop is **purely visual** (adds a red border) — it carries no ARIA semantics.',
108
116
  ' The consumer is still responsible for communicating the error state to assistive tech — e.g.',
109
117
  ' setting `aria-invalid` on the associated input or wiring `aria-describedby` to an error message.',
118
+ '- `disabled` sets `pointer-events: none` via CSS, so `onClick` genuinely cannot fire while disabled —',
119
+ ' no defensive guard is needed inside the handler itself.',
120
+ '',
121
+ '---',
122
+ '',
123
+ '#### Focus, hover, and active are CSS-only',
124
+ 'There is no `focused` / `hovered` / `active` prop. Each variant defines its own `:focus`, `:hover`,',
125
+ 'and `:active` rules directly in `button.scss` — these are native pseudo-classes on the underlying',
126
+ '`<button>`, not something Storybook Controls can toggle. To see them, interact with the rendered',
127
+ 'button in the canvas (click, tab to it, or hover over it).',
128
+ '',
129
+ '---',
130
+ '',
131
+ '#### The `` `[key: `data-${string}`]: unknown` `` index signature',
132
+ '`ButtonProps` includes a narrow catch-all that matches **only** `data-*` keys, spread via `...rest`',
133
+ 'straight onto the native `<button>` element — the one open-ended attribute pattern with no fixed',
134
+ 'name list (`data-testid`, `data-component`, etc.). `aria-*` and all standard HTML attributes are',
135
+ 'separately and fully typed via `ButtonHTMLAttributes`, so they do not need this catch-all. Because',
136
+ 'the signature is scoped to `data-*`, a typo in a known prop name (e.g. `isRond` instead of `isRound`)',
137
+ 'is a **compile error**, not a silent unrecognised DOM attribute — this used to be a blanket',
138
+ '`[key: string]: unknown` that swallowed any misspelled or removed prop name; it was narrowed for',
139
+ 'exactly this reason.',
110
140
  '',
111
141
  '---',
112
142
  '',
@@ -209,10 +239,9 @@ const meta = {
209
239
  children: {
210
240
  control: 'text',
211
241
  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`.',
242
+ 'Button label content. Accepts any `React.ReactNode`, including `Icon` elements placed before',
243
+ 'and/or after the label text — Button renders `children` as-is with no separate icon slots.',
244
+ 'For an icon-only button, set `isRound` and always pass `screenReaderText` on the `Icon`.',
216
245
  ].join(' '),
217
246
  table: {
218
247
  type: { summary: 'React.ReactNode' },
@@ -256,94 +285,16 @@ const meta = {
256
285
  defaultValue: { summary: 'true' },
257
286
  },
258
287
  },
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: {
294
- control: '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',
288
+ isRound: {
289
+ control: 'boolean',
340
290
  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.',
291
+ 'Applies equal padding on all sides (`ds-button--round`) for a square, compact button.',
292
+ 'Commonly used for icon-only buttons — in that case, always pass `screenReaderText` on the child',
293
+ '`Icon`, since it becomes the button\'s only accessible label.',
344
294
  ].join(' '),
345
295
  table: {
346
- type: { summary: 'string' },
296
+ type: { summary: 'boolean' },
297
+ defaultValue: { summary: 'false' },
347
298
  },
348
299
  },
349
300
  borderless: {
@@ -518,10 +469,9 @@ const LoadingCompositionTemplate = () => {
518
469
  variant="primary"
519
470
  type="button"
520
471
  disabled={loading}
521
- iconLeftName={loading ? 'loader' : undefined}
522
- iconLeftScreenReaderText={loading ? 'Loading, please wait' : undefined}
523
472
  onClick={handleClick}
524
473
  >
474
+ {loading && <Icon name="loader" screenReaderText="Loading, please wait" />}
525
475
  {loading ? 'Exporting...' : 'Export to CSV'}
526
476
  </Button>
527
477
  </div>
@@ -545,7 +495,7 @@ export const Default: Story = withDescription(
545
495
  },
546
496
  render: args => <Button {...args} />,
547
497
  },
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.',
498
+ 'The interactive canvas story — every prop is wired to the **Controls** panel. Use the controls to explore variants, sizes, and states. See the `WithIconLeft` / `WithIconRight` / `IconOnly` stories for icon composition, since icons are rendered via `children` rather than a Controls-editable prop.',
549
499
  );
550
500
 
551
501
  export const Primary: Story = withDescription(
@@ -721,7 +671,12 @@ export const Dropdown: Story = withDescription(
721
671
  args: {
722
672
  variant: 'dropdown',
723
673
  size: 'M',
724
- children: 'Select year group',
674
+ children: (
675
+ <>
676
+ Select year group
677
+ <Icon name="chevron-down" />
678
+ </>
679
+ ),
725
680
  },
726
681
  parameters: {
727
682
  docs: {
@@ -729,11 +684,13 @@ export const Dropdown: Story = withDescription(
729
684
  language: 'tsx',
730
685
  code: `
731
686
  import { Button } from '@arbor-education/design-system.components';
687
+ import { Icon } from '@arbor-education/design-system.components';
732
688
 
733
689
  function ButtonDropdownExample() {
734
690
  return (
735
- <Button variant="dropdown" size="M" iconRightName="chevron-down" iconRightScreenReaderText="Open dropdown">
691
+ <Button variant="dropdown" size="M">
736
692
  Select year group
693
+ <Icon name="chevron-down" />
737
694
  </Button>
738
695
  );
739
696
  }
@@ -748,8 +705,8 @@ export default ButtonDropdownExample;
748
705
  'The `dropdown` variant has `justify-content: space-between` and specific padding designed for use as a',
749
706
  'dropdown trigger — a trailing chevron icon aligns to the right edge automatically. Used internally by',
750
707
  '`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.',
708
+ 'dropdown triggers. Pair it with a trailing chevron `Icon` (e.g. `<Icon name="chevron-down" />`) for the',
709
+ 'canonical look — it is decorative, so it does not need `screenReaderText` when a visible label is present.',
753
710
  ].join(' '),
754
711
  );
755
712
 
@@ -940,9 +897,12 @@ export const WithIconLeft: Story = withDescription(
940
897
  args: {
941
898
  variant: 'primary',
942
899
  size: 'M',
943
- children: 'Export to CSV',
944
- iconLeftName: 'download',
945
- iconLeftScreenReaderText: 'Download',
900
+ children: (
901
+ <>
902
+ <Icon name="download" />
903
+ Export to CSV
904
+ </>
905
+ ),
946
906
  },
947
907
  parameters: {
948
908
  docs: {
@@ -950,10 +910,12 @@ export const WithIconLeft: Story = withDescription(
950
910
  language: 'tsx',
951
911
  code: `
952
912
  import { Button } from '@arbor-education/design-system.components';
913
+ import { Icon } from '@arbor-education/design-system.components';
953
914
 
954
915
  function ButtonWithIconLeftExample() {
955
916
  return (
956
- <Button variant="primary" size="M" iconLeftName="download" iconLeftScreenReaderText="Download">
917
+ <Button variant="primary" size="M">
918
+ <Icon name="download" />
957
919
  Export to CSV
958
920
  </Button>
959
921
  );
@@ -965,7 +927,7 @@ export default ButtonWithIconLeftExample;
965
927
  },
966
928
  },
967
929
  },
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.',
930
+ 'Icon placed before the label by rendering an `Icon` as the first child. `Icon` defaults to `size={16}` and `color="currentColor"`, so it inherits the button\'s text colour automatically. The icon sits next to a visible label here, so it is left decorative (no `screenReaderText`). Use leading icons for actions where the icon reinforces the verb: download, add, send.',
969
931
  );
970
932
 
971
933
  export const WithIconRight: Story = withDescription(
@@ -973,9 +935,12 @@ export const WithIconRight: Story = withDescription(
973
935
  args: {
974
936
  variant: 'secondary',
975
937
  size: 'M',
976
- children: 'Send to parents',
977
- iconRightName: 'send',
978
- iconRightScreenReaderText: 'Send',
938
+ children: (
939
+ <>
940
+ Send to parents
941
+ <Icon name="send" />
942
+ </>
943
+ ),
979
944
  },
980
945
  parameters: {
981
946
  docs: {
@@ -983,11 +948,13 @@ export const WithIconRight: Story = withDescription(
983
948
  language: 'tsx',
984
949
  code: `
985
950
  import { Button } from '@arbor-education/design-system.components';
951
+ import { Icon } from '@arbor-education/design-system.components';
986
952
 
987
953
  function ButtonWithIconRightExample() {
988
954
  return (
989
- <Button variant="secondary" size="M" iconRightName="send" iconRightScreenReaderText="Send">
955
+ <Button variant="secondary" size="M">
990
956
  Send to parents
957
+ <Icon name="send" />
991
958
  </Button>
992
959
  );
993
960
  }
@@ -998,7 +965,7 @@ export default ButtonWithIconRightExample;
998
965
  },
999
966
  },
1000
967
  },
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.',
968
+ 'Icon placed after the label by rendering an `Icon` as the last child. Trailing icons typically indicate direction or outcome: `arrow-right` for "next", `external-link` for "opens in new tab", `chevron-down` for "opens a menu". Pair a trailing chevron `Icon` with the `dropdown` variant for SelectDropdown-style triggers.',
1002
969
  );
1003
970
 
1004
971
  export const WithBothIcons: Story = withDescription(
@@ -1006,11 +973,13 @@ export const WithBothIcons: Story = withDescription(
1006
973
  args: {
1007
974
  variant: 'primary',
1008
975
  size: 'M',
1009
- children: 'Generate report',
1010
- iconLeftName: 'sparkles',
1011
- iconLeftScreenReaderText: 'AI generated',
1012
- iconRightName: 'arrow-right',
1013
- iconRightScreenReaderText: 'Continue',
976
+ children: (
977
+ <>
978
+ <Icon name="sparkles" screenReaderText="AI generated" />
979
+ Generate report
980
+ <Icon name="arrow-right" />
981
+ </>
982
+ ),
1014
983
  },
1015
984
  parameters: {
1016
985
  docs: {
@@ -1018,18 +987,14 @@ export const WithBothIcons: Story = withDescription(
1018
987
  language: 'tsx',
1019
988
  code: `
1020
989
  import { Button } from '@arbor-education/design-system.components';
990
+ import { Icon } from '@arbor-education/design-system.components';
1021
991
 
1022
992
  function ButtonWithBothIconsExample() {
1023
993
  return (
1024
- <Button
1025
- variant="primary"
1026
- size="M"
1027
- iconLeftName="sparkles"
1028
- iconLeftScreenReaderText="AI generated"
1029
- iconRightName="arrow-right"
1030
- iconRightScreenReaderText="Continue"
1031
- >
994
+ <Button variant="primary" size="M">
995
+ <Icon name="sparkles" screenReaderText="AI generated" />
1032
996
  Generate report
997
+ <Icon name="arrow-right" />
1033
998
  </Button>
1034
999
  );
1035
1000
  }
@@ -1040,7 +1005,7 @@ export default ButtonWithBothIconsExample;
1040
1005
  },
1041
1006
  },
1042
1007
  },
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.',
1008
+ 'Both a leading and trailing icon used simultaneously. The leading icon carries its own `screenReaderText` ("AI generated") because that meaning is not otherwise conveyed by the label — the trailing `arrow-right` is purely decorative (proceed/continue) so it has none. The dual-icon pattern is rare — only use it when both icons add distinct meaning, and keep the label short enough that the button does not become unwieldy.',
1044
1009
  );
1045
1010
 
1046
1011
  export const IconOnly: Story = withDescription(
@@ -1048,8 +1013,8 @@ export const IconOnly: Story = withDescription(
1048
1013
  args: {
1049
1014
  variant: 'secondary',
1050
1015
  size: 'M',
1051
- iconRightName: '3-dot',
1052
- iconRightScreenReaderText: 'More actions',
1016
+ isRound: true,
1017
+ children: <Icon name="3-dot" screenReaderText="More actions" />,
1053
1018
  },
1054
1019
  parameters: {
1055
1020
  docs: {
@@ -1057,9 +1022,14 @@ export const IconOnly: Story = withDescription(
1057
1022
  language: 'tsx',
1058
1023
  code: `
1059
1024
  import { Button } from '@arbor-education/design-system.components';
1025
+ import { Icon } from '@arbor-education/design-system.components';
1060
1026
 
1061
1027
  function ButtonIconOnlyExample() {
1062
- return <Button variant="secondary" size="M" iconRightName="3-dot" iconRightScreenReaderText="More actions" />;
1028
+ return (
1029
+ <Button variant="secondary" size="M" isRound>
1030
+ <Icon name="3-dot" screenReaderText="More actions" />
1031
+ </Button>
1032
+ );
1063
1033
  }
1064
1034
 
1065
1035
  export default ButtonIconOnlyExample;
@@ -1068,7 +1038,7 @@ export default ButtonIconOnlyExample;
1068
1038
  },
1069
1039
  },
1070
1040
  },
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.',
1041
+ 'Set `isRound` for square, no-padding sizing (`ds-button--round`) when there is no visible label. **Always** pass `screenReaderText` on the `Icon` in this mode — the icon name `"3-dot"` is meaningless to a screen reader, and with no other text content the button would otherwise have no accessible name at all. This story uses `"More actions"` which clearly communicates the button\'s purpose. Note: `isRound` already zeroes padding via CSS, so combining it with `hasHorizontalPadding={false}` is redundant — the two do not stack.',
1072
1042
  );
1073
1043
 
1074
1044
  export const IconOnlyAccessibility: Story = withDescription(
@@ -1077,13 +1047,12 @@ export const IconOnlyAccessibility: Story = withDescription(
1077
1047
  <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xlarge)' }}>
1078
1048
  <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1079
1049
  <p style={{ margin: 0, color: 'var(--color-semantic-destructive-600)' }}>
1080
- Bad — screen reader announces "3-dot" or "x" (the raw icon name)
1050
+ Bad — no accessible name at all (screenReaderText omitted)
1081
1051
  </p>
1082
1052
  <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" />
1053
+ <Button variant="secondary" type="button" isRound><Icon name="3-dot" /></Button>
1054
+ <Button variant="secondary" type="button" isRound><Icon name="x" /></Button>
1055
+ <Button variant="secondary" type="button" isRound><Icon name="pencil" /></Button>
1087
1056
  </div>
1088
1057
  </div>
1089
1058
  <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
@@ -1091,9 +1060,9 @@ export const IconOnlyAccessibility: Story = withDescription(
1091
1060
  Good — screen reader announces a human-readable action name
1092
1061
  </p>
1093
1062
  <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" />
1063
+ <Button variant="secondary" type="button" isRound><Icon name="3-dot" screenReaderText="More actions" /></Button>
1064
+ <Button variant="secondary" type="button" isRound><Icon name="x" screenReaderText="Dismiss" /></Button>
1065
+ <Button variant="secondary" type="button" isRound><Icon name="pencil" screenReaderText="Edit student details" /></Button>
1097
1066
  </div>
1098
1067
  </div>
1099
1068
  </div>
@@ -1104,22 +1073,23 @@ export const IconOnlyAccessibility: Story = withDescription(
1104
1073
  language: 'tsx',
1105
1074
  code: `
1106
1075
  import { Button } from '@arbor-education/design-system.components';
1076
+ import { Icon } from '@arbor-education/design-system.components';
1107
1077
 
1108
1078
  function ButtonIconOnlyAccessibilityExample() {
1109
1079
  return (
1110
1080
  <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1111
- {/* Bad — screen reader announces raw icon names */}
1081
+ {/* Bad — no accessible name at all */}
1112
1082
  <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" />
1083
+ <Button variant="secondary" type="button" isRound><Icon name="3-dot" /></Button>
1084
+ <Button variant="secondary" type="button" isRound><Icon name="x" /></Button>
1085
+ <Button variant="secondary" type="button" isRound><Icon name="pencil" /></Button>
1116
1086
  </div>
1117
1087
 
1118
1088
  {/* Good — human-readable action names */}
1119
1089
  <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" />
1090
+ <Button variant="secondary" type="button" isRound><Icon name="3-dot" screenReaderText="More actions" /></Button>
1091
+ <Button variant="secondary" type="button" isRound><Icon name="x" screenReaderText="Dismiss" /></Button>
1092
+ <Button variant="secondary" type="button" isRound><Icon name="pencil" screenReaderText="Edit student details" /></Button>
1123
1093
  </div>
1124
1094
  </div>
1125
1095
  );
@@ -1131,7 +1101,7 @@ export default ButtonIconOnlyAccessibilityExample;
1131
1101
  },
1132
1102
  },
1133
1103
  },
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.',
1104
+ 'Icon-only accessibility contrast. The top row omits `screenReaderText` on the `Icon` — since Button no longer supplies a fallback, these buttons have **no accessible name at all**. The bottom row sets `screenReaderText` with human-readable action names. Always pass `screenReaderText` on the `Icon` when using `isRound` — the icon name is almost never descriptive enough in context, and unlike before, Button will not fall back to it for you.',
1135
1105
  );
1136
1106
 
1137
1107
  export const Disabled: Story = withDescription(
@@ -1190,72 +1160,6 @@ export default ButtonErrorStateExample;
1190
1160
  '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
1161
  );
1192
1162
 
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
1163
  export const FormTypeAttribute: Story = withDescription(
1260
1164
  {
1261
1165
  render: FormTypeAttributeTemplate,
@@ -1315,10 +1219,10 @@ export default FormTypeAttributeExample;
1315
1219
  },
1316
1220
  },
1317
1221
  [
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.',
1222
+ '**Critical gotcha demo.** `type` is a typed prop, but `Button` never defaults it — there is no',
1223
+ '`type = \'button\'` fallback in the component. This means `<Button>` never sets a `type` attribute',
1224
+ 'on its own, and browsers default to `type="submit"`. Any `<Button>` inside a `<form>` will submit',
1225
+ 'that form on click unless you pass `type="button"` explicitly.',
1322
1226
  '',
1323
1227
  'This story shows a form with both patterns side by side. The "Cancel" button uses `type="button"`',
1324
1228
  'so clicking it does not submit. The "Save attendance" button uses `type="submit"` to intentionally',
@@ -1326,6 +1230,77 @@ export default FormTypeAttributeExample;
1326
1230
  ].join(' '),
1327
1231
  );
1328
1232
 
1233
+ export const Borderless: Story = withDescription(
1234
+ {
1235
+ args: {
1236
+ variant: 'tertiary',
1237
+ size: 'M',
1238
+ borderless: true,
1239
+ children: (
1240
+ <>
1241
+ <Icon name="settings" />
1242
+ Settings
1243
+ </>
1244
+ ),
1245
+ },
1246
+ parameters: {
1247
+ docs: {
1248
+ source: {
1249
+ language: 'tsx',
1250
+ code: `
1251
+ import { Button } from '@arbor-education/design-system.components';
1252
+ import { Icon } from '@arbor-education/design-system.components';
1253
+
1254
+ function ButtonBorderlessExample() {
1255
+ return (
1256
+ <Button variant="tertiary" size="M" borderless>
1257
+ <Icon name="settings" />
1258
+ Settings
1259
+ </Button>
1260
+ );
1261
+ }
1262
+
1263
+ export default ButtonBorderlessExample;
1264
+ `.trim(),
1265
+ },
1266
+ },
1267
+ },
1268
+ },
1269
+ '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 a leading icon.',
1270
+ );
1271
+
1272
+ export const NoHorizontalPadding: Story = withDescription(
1273
+ {
1274
+ args: {
1275
+ variant: 'text-link',
1276
+ size: 'M',
1277
+ children: 'View full attendance report',
1278
+ hasHorizontalPadding: false,
1279
+ },
1280
+ parameters: {
1281
+ docs: {
1282
+ source: {
1283
+ language: 'tsx',
1284
+ code: `
1285
+ import { Button } from '@arbor-education/design-system.components';
1286
+
1287
+ function ButtonNoHorizontalPaddingExample() {
1288
+ return (
1289
+ <Button variant="text-link" size="M" hasHorizontalPadding={false}>
1290
+ View full attendance report
1291
+ </Button>
1292
+ );
1293
+ }
1294
+
1295
+ export default ButtonNoHorizontalPaddingExample;
1296
+ `.trim(),
1297
+ },
1298
+ },
1299
+ },
1300
+ },
1301
+ '`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.',
1302
+ );
1303
+
1329
1304
  export const LoadingComposition: Story = withDescription(
1330
1305
  {
1331
1306
  render: LoadingCompositionTemplate,
@@ -1336,6 +1311,7 @@ export const LoadingComposition: Story = withDescription(
1336
1311
  code: `
1337
1312
  import { useState } from 'react';
1338
1313
  import { Button } from '@arbor-education/design-system.components';
1314
+ import { Icon } from '@arbor-education/design-system.components';
1339
1315
 
1340
1316
  function LoadingCompositionExample() {
1341
1317
  const [loading, setLoading] = useState(false);
@@ -1359,10 +1335,9 @@ function LoadingCompositionExample() {
1359
1335
  variant="primary"
1360
1336
  type="button"
1361
1337
  disabled={loading}
1362
- iconLeftName={loading ? 'loader' : undefined}
1363
- iconLeftScreenReaderText={loading ? 'Loading, please wait' : undefined}
1364
1338
  onClick={handleClick}
1365
1339
  >
1340
+ {loading && <Icon name="loader" screenReaderText="Loading, please wait" />}
1366
1341
  {loading ? 'Exporting...' : 'Export to CSV'}
1367
1342
  </Button>
1368
1343
  </div>
@@ -1376,10 +1351,10 @@ export default LoadingCompositionExample;
1376
1351
  },
1377
1352
  },
1378
1353
  [
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.',
1354
+ 'Button has **no built-in loading state** — compose one with `disabled`, a conditional `Icon`, and a label change.',
1355
+ 'This pattern uses `disabled={loading}` to block interaction during the async operation, a conditionally',
1356
+ 'rendered `<Icon name="loader" screenReaderText="Loading, please wait" />` to show a spinner icon with an',
1357
+ 'accessible announcement. After 2 seconds the mock export resolves and the button returns to its default state.',
1383
1358
  '',
1384
1359
  'The `loader` icon (`LoaderCircle` from Lucide) does not spin by default — animate it with a CSS',
1385
1360
  '`animation: spin 1s linear infinite` keyframe on the `svg` within the button if your design requires it.',
@@ -1460,5 +1435,5 @@ export default ClickTrackingExample;
1460
1435
  },
1461
1436
  },
1462
1437
  },
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.',
1438
+ '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. The `onClick` signature also accepts `...otherArgs: unknown[]` (non-standard) — this exists to support legacy callers that wrap `Button` and invoke the handler with extra arguments beyond the click event.',
1464
1439
  );