@arbor-education/design-system.components 3.0.2 → 3.2.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 (89) hide show
  1. package/.github/pull_request_template.md +4 -3
  2. package/CHANGELOG.md +16 -0
  3. package/CONTRIBUTING.md +1 -0
  4. package/dist/components/formField/FormField.d.ts +34 -18
  5. package/dist/components/formField/FormField.d.ts.map +1 -1
  6. package/dist/components/formField/FormField.js +5 -1
  7. package/dist/components/formField/FormField.js.map +1 -1
  8. package/dist/components/formField/FormField.stories.d.ts +4 -0
  9. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  10. package/dist/components/formField/FormField.stories.js +314 -7
  11. package/dist/components/formField/FormField.stories.js.map +1 -1
  12. package/dist/components/formField/FormField.test.js +66 -0
  13. package/dist/components/formField/FormField.test.js.map +1 -1
  14. package/dist/components/formField/inputs/checkbox/CheckboxGroup.d.ts +1 -0
  15. package/dist/components/formField/inputs/checkbox/CheckboxGroup.d.ts.map +1 -1
  16. package/dist/components/formField/inputs/checkbox/CheckboxGroup.js +2 -2
  17. package/dist/components/formField/inputs/checkbox/CheckboxGroup.js.map +1 -1
  18. package/dist/components/formField/inputs/checkbox/CheckboxInput.d.ts +1 -0
  19. package/dist/components/formField/inputs/checkbox/CheckboxInput.d.ts.map +1 -1
  20. package/dist/components/formField/inputs/checkbox/CheckboxInput.js +2 -2
  21. package/dist/components/formField/inputs/checkbox/CheckboxInput.js.map +1 -1
  22. package/dist/components/formField/inputs/radio/RadioButtonGroup.d.ts +1 -0
  23. package/dist/components/formField/inputs/radio/RadioButtonGroup.d.ts.map +1 -1
  24. package/dist/components/formField/inputs/radio/RadioButtonGroup.js +2 -2
  25. package/dist/components/formField/inputs/radio/RadioButtonGroup.js.map +1 -1
  26. package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.d.ts +2 -2
  27. package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.d.ts.map +1 -1
  28. package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.js +0 -1
  29. package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.js.map +1 -1
  30. package/dist/components/formField/inputs/time/TimeInput.d.ts +1 -1
  31. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +1 -1
  32. package/dist/components/icon/Icon.d.ts.map +1 -1
  33. package/dist/components/icon/Icon.js +18 -3
  34. package/dist/components/icon/Icon.js.map +1 -1
  35. package/dist/components/icon/Icon.stories.d.ts +4 -2
  36. package/dist/components/icon/Icon.stories.d.ts.map +1 -1
  37. package/dist/components/icon/Icon.stories.js +150 -102
  38. package/dist/components/icon/Icon.stories.js.map +1 -1
  39. package/dist/components/icon/Icon.test.js +146 -6
  40. package/dist/components/icon/Icon.test.js.map +1 -1
  41. package/dist/components/icon/LucideIcon.d.ts +17 -0
  42. package/dist/components/icon/LucideIcon.d.ts.map +1 -0
  43. package/dist/components/icon/LucideIcon.js +73 -0
  44. package/dist/components/icon/LucideIcon.js.map +1 -0
  45. package/dist/components/icon/allowedIcons.d.ts +44 -108
  46. package/dist/components/icon/allowedIcons.d.ts.map +1 -1
  47. package/dist/components/icon/allowedIcons.js +52 -112
  48. package/dist/components/icon/allowedIcons.js.map +1 -1
  49. package/dist/components/icon/types.d.ts +5 -4
  50. package/dist/components/icon/types.d.ts.map +1 -1
  51. package/dist/components/tabs/Tabs.d.ts +1 -1
  52. package/dist/components/tabs/Tabs.stories.d.ts +1 -1
  53. package/dist/components/tabs/Tabs.stories.js +1 -1
  54. package/dist/components/tabs/Tabs.stories.js.map +1 -1
  55. package/dist/components/tabs/TabsItem.d.ts +2 -2
  56. package/dist/components/tabs/TabsItem.d.ts.map +1 -1
  57. package/dist/components/tabs/TabsItem.stories.d.ts +5 -5
  58. package/dist/components/tabs/TabsItem.stories.d.ts.map +1 -1
  59. package/dist/components/tabs/TabsItem.stories.js +7 -4
  60. package/dist/components/tabs/TabsItem.stories.js.map +1 -1
  61. package/dist/components/toggle/Toggle.d.ts +7 -1
  62. package/dist/components/toggle/Toggle.d.ts.map +1 -1
  63. package/dist/components/toggle/Toggle.js +2 -2
  64. package/dist/components/toggle/Toggle.js.map +1 -1
  65. package/dist/components/toggle/Toggle.stories.d.ts +1 -1
  66. package/dist/index.css +21 -0
  67. package/dist/index.css.map +1 -1
  68. package/package.json +1 -1
  69. package/src/components/formField/FormField.stories.tsx +398 -7
  70. package/src/components/formField/FormField.test.tsx +100 -0
  71. package/src/components/formField/FormField.tsx +46 -27
  72. package/src/components/formField/inputs/checkbox/CheckboxGroup.tsx +3 -2
  73. package/src/components/formField/inputs/checkbox/CheckboxInput.tsx +3 -0
  74. package/src/components/formField/inputs/checkbox/checkboxInput.scss +4 -0
  75. package/src/components/formField/inputs/radio/RadioButtonGroup.tsx +11 -1
  76. package/src/components/formField/inputs/radio/radioButtonInput.scss +8 -0
  77. package/src/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.tsx +2 -2
  78. package/src/components/icon/Icon.stories.tsx +224 -119
  79. package/src/components/icon/Icon.test.tsx +178 -6
  80. package/src/components/icon/Icon.tsx +25 -3
  81. package/src/components/icon/LucideIcon.tsx +94 -0
  82. package/src/components/icon/allowedIcons.tsx +67 -207
  83. package/src/components/icon/lucideDynamic.d.ts +13 -0
  84. package/src/components/icon/types.ts +5 -4
  85. package/src/components/tabs/Tabs.stories.tsx +1 -1
  86. package/src/components/tabs/TabsItem.stories.tsx +8 -4
  87. package/src/components/tabs/TabsItem.tsx +2 -2
  88. package/src/components/toggle/Toggle.tsx +13 -3
  89. package/src/components/toggle/toggle.scss +22 -0
@@ -1,16 +1,19 @@
1
1
  import classNames from 'classnames';
2
2
  import { Combobox } from 'Components/combobox/Combobox';
3
- import type { ComboboxProps } from 'Components/combobox/types';
4
- import { DatePicker, type DatePickerProps } from 'Components/datePicker/DatePicker';
5
- import { DateTimePicker, type DateTimePickerProps } from 'Components/dateTimePicker/DateTimePicker';
3
+ import { DatePicker } from 'Components/datePicker/DatePicker';
4
+ import { DateTimePicker } from 'Components/dateTimePicker/DateTimePicker';
6
5
  import { Icon } from '../icon/Icon.js';
7
- import { ColourPickerDropdown, type ColourPickerDropdownProps } from './inputs/colourPickerDropdown/ColourPickerDropdown.js';
8
- import { NumberInput, type NumberInputProps } from './inputs/number/NumberInput.js';
9
- import { SelectDropdown, type SelectDropdownInputProps } from './inputs/selectDropdown/SelectDropdown.js';
10
- import { TextInput, type TextInputProps } from './inputs/text/TextInput.js';
11
- import { TimeInput, type TimeInputProps } from './inputs/time/TimeInput.js';
12
- import { TextArea, type TextAreaProps } from './inputs/textArea/TextArea.js';
6
+ import { ColourPickerDropdown } from './inputs/colourPickerDropdown/ColourPickerDropdown.js';
7
+ import { NumberInput } from './inputs/number/NumberInput.js';
8
+ import { SelectDropdown } from './inputs/selectDropdown/SelectDropdown.js';
9
+ import { TextInput } from './inputs/text/TextInput.js';
10
+ import { TimeInput } from './inputs/time/TimeInput.js';
11
+ import { TextArea } from './inputs/textArea/TextArea.js';
13
12
  import { Label } from './label/Label.js';
13
+ import { CheckboxInput } from './inputs/checkbox/CheckboxInput.js';
14
+ import { CheckboxGroup } from './inputs/checkbox/CheckboxGroup.js';
15
+ import { RadioButtonGroup } from './inputs/radio/RadioButtonGroup.js';
16
+ import { Toggle } from 'Components/toggle/Toggle.js';
14
17
 
15
18
  export type FormFieldProps = {
16
19
  className?: string;
@@ -21,15 +24,19 @@ export type FormFieldProps = {
21
24
  helperLinkUrl?: string;
22
25
  errorText?: string;
23
26
  } & (
24
- | { inputType?: 'text'; inputProps?: TextInputProps }
25
- | { inputType?: 'textarea'; inputProps?: TextAreaProps }
26
- | { inputType?: 'number'; inputProps?: NumberInputProps }
27
- | { inputType?: 'time'; inputProps?: TimeInputProps }
28
- | { inputType?: 'colourPicker'; inputProps?: ColourPickerDropdownProps }
29
- | { inputType?: 'selectDropdown'; inputProps?: SelectDropdownInputProps }
30
- | { inputType?: 'datePicker'; inputProps?: DatePickerProps }
31
- | { inputType?: 'dateTimePicker'; inputProps?: DateTimePickerProps }
32
- | { inputType?: 'combobox'; inputProps?: ComboboxProps }
27
+ | { inputType?: 'text'; inputProps?: TextInput.Props }
28
+ | { inputType?: 'textarea'; inputProps?: TextArea.Props }
29
+ | { inputType?: 'number'; inputProps?: NumberInput.Props }
30
+ | { inputType?: 'time'; inputProps?: TimeInput.Props }
31
+ | { inputType?: 'colourPicker'; inputProps?: ColourPickerDropdown.Props }
32
+ | { inputType?: 'selectDropdown'; inputProps?: SelectDropdown.Props }
33
+ | { inputType?: 'datePicker'; inputProps?: DatePicker.Props }
34
+ | { inputType?: 'dateTimePicker'; inputProps?: DateTimePicker.Props }
35
+ | { inputType?: 'combobox'; inputProps?: Combobox.Props }
36
+ | { inputType?: 'checkbox'; inputProps: CheckboxInput.Props }
37
+ | { inputType?: 'checkboxGroup'; inputProps: CheckboxGroup.Props }
38
+ | { inputType?: 'toggle'; inputProps: Toggle.Props }
39
+ | { inputType?: 'radio'; inputProps: RadioButtonGroup.Props }
33
40
  );
34
41
 
35
42
  export const FormField = (props: FormFieldProps) => {
@@ -61,31 +68,43 @@ export const FormField = (props: FormFieldProps) => {
61
68
  <span id={`${id}-description`} className="ds-form-field__description">{fieldDescription}</span>
62
69
  )}
63
70
  {inputType === 'text' && (
64
- <TextInput {...sharedProps} {...(inputProps as TextInputProps)} />
71
+ <TextInput {...sharedProps} {...(inputProps as TextInput.Props)} />
65
72
  )}
66
73
  {inputType === 'textarea' && (
67
- <TextArea {...sharedProps} {...(inputProps as TextAreaProps)} />
74
+ <TextArea {...sharedProps} {...(inputProps as TextArea.Props)} />
68
75
  )}
69
76
  {inputType === 'number' && (
70
- <NumberInput {...sharedProps} {...(inputProps as NumberInputProps)} />
77
+ <NumberInput {...sharedProps} {...(inputProps as NumberInput.Props)} />
71
78
  )}
72
79
  {inputType === 'time' && (
73
- <TimeInput {...sharedProps} {...(inputProps as TimeInputProps)} />
80
+ <TimeInput {...sharedProps} {...(inputProps as TimeInput.Props)} />
74
81
  )}
75
82
  {inputType === 'colourPicker' && (
76
- <ColourPickerDropdown {...sharedProps} {...(inputProps as ColourPickerDropdownProps)} />
83
+ <ColourPickerDropdown {...sharedProps} {...(inputProps as ColourPickerDropdown.Props)} />
77
84
  )}
78
85
  {inputType === 'selectDropdown' && (
79
- <SelectDropdown {...sharedProps} {...(inputProps as SelectDropdownInputProps)} />
86
+ <SelectDropdown {...sharedProps} {...(inputProps as SelectDropdown.Props)} />
80
87
  )}
81
88
  {inputType === 'datePicker' && (
82
- <DatePicker {...sharedProps} {...(inputProps as DatePickerProps)} />
89
+ <DatePicker {...sharedProps} {...(inputProps as DatePicker.Props)} />
83
90
  )}
84
91
  {inputType === 'dateTimePicker' && (
85
- <DateTimePicker {...sharedProps} {...(inputProps as DateTimePickerProps)} />
92
+ <DateTimePicker {...sharedProps} {...(inputProps as DateTimePicker.Props)} />
86
93
  )}
87
94
  {inputType === 'combobox' && (
88
- <Combobox {...sharedProps} {...(inputProps as ComboboxProps)} />
95
+ <Combobox {...sharedProps} {...(inputProps as Combobox.Props)} />
96
+ )}
97
+ {inputType === 'checkbox' && (
98
+ <CheckboxInput {...sharedProps} {...(inputProps as CheckboxInput.Props)} />
99
+ )}
100
+ {inputType === 'checkboxGroup' && (
101
+ <CheckboxGroup {...sharedProps} {...(inputProps as CheckboxGroup.Props)} />
102
+ )}
103
+ {inputType === 'toggle' && (
104
+ <Toggle {...sharedProps} {...(inputProps as Toggle.Props)} />
105
+ )}
106
+ {inputType === 'radio' && (
107
+ <RadioButtonGroup {...sharedProps} {...(inputProps as RadioButtonGroup.Props)} />
89
108
  )}
90
109
  {((helperLinkText && helperLinkUrl) || errorText) && (
91
110
  <div className="ds-form-field__message">
@@ -3,14 +3,15 @@ import { CheckboxInput, type CheckboxInputProps } from './CheckboxInput.js';
3
3
 
4
4
  export type CheckboxGroupProps = {
5
5
  options: (CheckboxInputProps & { id: string })[];
6
+ hasError?: boolean;
6
7
  } & FieldsetProps;
7
8
 
8
9
  export const CheckboxGroup = (props: CheckboxGroupProps) => {
9
- const { options, ...rest } = props;
10
+ const { options, hasError, ...rest } = props;
10
11
  return (
11
12
  <Fieldset {...rest}>
12
13
  {options.map((option: CheckboxInputProps) => (
13
- <CheckboxInput key={option.id} {...option} />
14
+ <CheckboxInput key={option.id} hasError={hasError} {...option} />
14
15
  ))}
15
16
  </Fieldset>
16
17
  );
@@ -5,6 +5,7 @@ import { Icon } from 'Components/icon/Icon';
5
5
  export type CheckboxInputProps = {
6
6
  indeterminate?: boolean;
7
7
  label?: string;
8
+ hasError?: boolean;
8
9
  } & InputHTMLAttributes<HTMLInputElement>;
9
10
 
10
11
  export const CheckboxInput = (props: CheckboxInputProps) => {
@@ -15,6 +16,7 @@ export const CheckboxInput = (props: CheckboxInputProps) => {
15
16
  checked = false,
16
17
  indeterminate = false,
17
18
  label,
19
+ hasError,
18
20
  ...rest
19
21
  } = props;
20
22
 
@@ -57,6 +59,7 @@ export const CheckboxInput = (props: CheckboxInputProps) => {
57
59
  checked={checked}
58
60
  onChange={onChange}
59
61
  aria-checked={indeterminate ? 'mixed' : checked}
62
+ aria-invalid={hasError}
60
63
  {...rest}
61
64
  />
62
65
  <span
@@ -64,6 +64,10 @@
64
64
  box-shadow: inset 0 0 0 var(--checkbox-input-control-unchecked-weight-border) var(--checkbox-input-control-unchecked-disabled-color-border);
65
65
  }
66
66
 
67
+ input[aria-invalid='true'] + .ds-checkbox-input {
68
+ box-shadow: inset 0 0 0 var(--checkbox-input-control-unchecked-weight-border) var(--form-field-text-error-color-border);
69
+ }
70
+
67
71
  input:checked:disabled + .ds-checkbox-input {
68
72
  background-color: var(--checkbox-input-control-checked-disabled-color-background);
69
73
  }
@@ -13,10 +13,19 @@ export type RadioButtonGroupProps = {
13
13
  options: RadioGroupOption[];
14
14
  checkedValue: string;
15
15
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
16
+ hasError?: boolean;
16
17
  } & Omit<FieldsetProps, 'onChange'>;
17
18
 
18
19
  export const RadioButtonGroup = (props: RadioButtonGroupProps) => {
19
- const { legend, name, options, checkedValue, onChange, ...rest } = props;
20
+ const {
21
+ legend,
22
+ name,
23
+ options,
24
+ checkedValue,
25
+ onChange,
26
+ hasError,
27
+ ...rest
28
+ } = props;
20
29
 
21
30
  return (
22
31
  <Fieldset legend={legend} {...rest}>
@@ -28,6 +37,7 @@ export const RadioButtonGroup = (props: RadioButtonGroupProps) => {
28
37
  label={option.label}
29
38
  checked={checkedValue === option.value}
30
39
  onChange={onChange}
40
+ hasError={hasError}
31
41
  />
32
42
  ))}
33
43
  </Fieldset>
@@ -75,6 +75,10 @@
75
75
  }
76
76
  }
77
77
 
78
+ .ds-radio-button-input__input[aria-invalid='true'] + .ds-radio-button-input__indicator {
79
+ border-color: var(--form-field-text-error-color-border);
80
+ }
81
+
78
82
  &:hover:not(:has(.ds-radio-button-input__input:disabled)) {
79
83
  .ds-radio-button-input__indicator {
80
84
  border-color: var(--checkbox-or-radio-button-group-hover-color-border);
@@ -93,6 +97,10 @@
93
97
  background-color: var(--checkbox-or-radio-button-group-selected-hover-color-check);
94
98
  }
95
99
  }
100
+
101
+ .ds-radio-button-input__input[aria-invalid='true'] + .ds-radio-button-input__indicator {
102
+ border-color: var(--form-field-text-error-color-border);
103
+ }
96
104
  }
97
105
  }
98
106
 
@@ -1,4 +1,4 @@
1
- import { allowedIcons } from 'Components/icon/allowedIcons';
1
+ import type { IconName } from 'Components/icon/allowedIcons';
2
2
  import { Icon } from 'Components/icon/Icon';
3
3
  import { Dropdown } from 'Components/dropdown/Dropdown';
4
4
  import classNames from 'classnames';
@@ -12,7 +12,7 @@ export type SelectDropdownItemBaseProps = {
12
12
  export type SelectDropdownItemProps = {
13
13
  value: string;
14
14
  label?: string;
15
- icon?: keyof typeof allowedIcons;
15
+ icon?: IconName;
16
16
  header?: string;
17
17
  group?: string;
18
18
  closeAfterSelection?: boolean;
@@ -1,3 +1,4 @@
1
+ import { useMemo, useState } from 'react';
1
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
3
  import {
3
4
  Controls,
@@ -10,7 +11,7 @@ import {
10
11
  } from '@storybook/addon-docs/blocks';
11
12
  import { Button } from 'Components/button/Button';
12
13
  import { Icon } from './Icon.js';
13
- import { allowedIcons } from './allowedIcons.js';
14
+ import { iconNames, type IconName } from './allowedIcons.js';
14
15
  import { iconSizes } from './types.js';
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -189,7 +190,7 @@ const DEVELOPER_NOTES = [
189
190
  '- **Status icons** — must be paired with visible text where possible (WCAG 1.4.1: use of colour).',
190
191
  ' Do not rely on icon colour alone to communicate success, warning, or error.',
191
192
  '- **Icon-only interactions** — must have accessible label + tooltip on hover/focus.',
192
- '- **Avoid `title` attributes** — Lucide SVGs sometimes include `<title>` elements; Arbor\'s `allowedIcons`',
193
+ '- **Avoid `title` attributes** — Lucide SVGs sometimes include `<title>` elements; Arbor\'s `Icon`',
193
194
  ' wrapper suppresses these via `aria-hidden`. Do not rely on SVG `title` for accessible names.',
194
195
  '- **"Opens in new tab"** — communicate this in text (`<span class="sr-only">opens in new tab</span>`),',
195
196
  ' not only via the `external-link` icon.',
@@ -207,7 +208,7 @@ const DEVELOPER_NOTES = [
207
208
  '| Type | Description |',
208
209
  '|---|---|',
209
210
  '| `Icon.Props` | Full props interface |',
210
- '| `Icon.Name` | Union of all icon name strings — see argTypes for full list |',
211
+ '| `Icon.Name` | Union of every valid name: the whole Lucide set, plus Arbor aliases and custom icons |',
211
212
  '| `Icon.Size` | `12 \\| 16 \\| 24` |',
212
213
  '| `Icon.CustomProps` | Props for custom SVG icons |',
213
214
  ].join('\n');
@@ -218,6 +219,11 @@ const RELATED_COMPONENTS = [
218
219
  '[Button](?path=/docs/components-button--docs) · [Banner](?path=/docs/components-banner--docs) · [Badge](?path=/docs/components-badge--docs) · [Dot](?path=/docs/components-dot--docs) · [Tag](?path=/docs/components-tag--docs)',
219
220
  ].join('\n');
220
221
 
222
+ const CATALOGUE_INTRO = [
223
+ 'The Arbor-curated names are grouped below. Every [Lucide](https://lucide.dev/icons) icon name is',
224
+ 'also valid — use the **Icon search** story above to find one by name.',
225
+ ].join('\n');
226
+
221
227
  const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
222
228
 
223
229
  function IconDocsPage() {
@@ -237,12 +243,101 @@ function IconDocsPage() {
237
243
  <DocHeading>Examples</DocHeading>
238
244
  <Stories title="" />
239
245
  <DocHeading>Icon catalogue</DocHeading>
246
+ <Markdown>{CATALOGUE_INTRO}</Markdown>
240
247
  <IconGalleryTemplate />
241
248
  <Markdown>{RELATED_COMPONENTS}</Markdown>
242
249
  </>
243
250
  );
244
251
  }
245
252
 
253
+ type IconCategory = {
254
+ heading: string;
255
+ icons: IconName[];
256
+ };
257
+
258
+ const ICON_CATEGORIES: IconCategory[] = [
259
+ {
260
+ heading: 'Navigation',
261
+ icons: [
262
+ 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right',
263
+ 'arrow-right-from-line', 'arrow-up-right',
264
+ 'chevron-up', 'chevron-down', 'chevron-left', 'chevron-right',
265
+ 'chevrons-left', 'chevrons-right',
266
+ 'corner-down-left', 'menu',
267
+ 'ellipsis', 'ellipsis-vertical', '3-dot', 'grab',
268
+ ],
269
+ },
270
+ {
271
+ heading: 'Actions',
272
+ icons: [
273
+ 'pencil', 'trash', 'save', 'copy', 'download', 'upload',
274
+ 'share', 'send', 'redo', 'undo', 'plus', 'minus',
275
+ 'circle-plus', 'iteration-ccw', 'switch-camera',
276
+ ],
277
+ },
278
+ {
279
+ heading: 'Status / severity',
280
+ icons: [
281
+ 'info', 'circle-alert', 'triangle-alert',
282
+ 'circle-check', 'circle-check-big', 'circle-x',
283
+ 'circle-help', 'check', 'check-solid', 'x', 'x-solid',
284
+ ],
285
+ },
286
+ {
287
+ heading: 'Content / media',
288
+ icons: [
289
+ 'file', 'files', 'clipboard', 'clipboard-list',
290
+ 'book-open', 'camera', 'mic', 'paperclip',
291
+ 'sheet', 'table',
292
+ 'sparkles', 'party-popper', 'lightbulb',
293
+ 'favourite-outline', 'favourite-filled',
294
+ ],
295
+ },
296
+ {
297
+ heading: 'Charts / data',
298
+ icons: [
299
+ 'chart-column-increasing', 'chart-spline',
300
+ 'trending-up', 'trending-down',
301
+ 'percent', 'circle-percent',
302
+ 'sorting', 'funnel', 'sliders-horizontal', 'list-filter-plus',
303
+ ],
304
+ },
305
+ {
306
+ heading: 'People / org',
307
+ icons: [
308
+ 'user', 'guardians', 'group', 'staff', 'graduation-cap',
309
+ ],
310
+ },
311
+ {
312
+ heading: 'System / state',
313
+ icons: [
314
+ 'eye', 'eye-off', 'lock', 'lock-open', 'loader',
315
+ 'pin', 'flag', 'house', 'history', 'clock-3', 'date',
316
+ 'settings', 'expand', 'shrink', 'link', 'external-link',
317
+ 'log-out', 'search', 'mail', 'phone', 'smartphone',
318
+ 'monitor', 'projector', 'archive',
319
+ ],
320
+ },
321
+ {
322
+ heading: 'Other / misc',
323
+ icons: [
324
+ 'dot', 'layout-list', 'list', 'grid-3x3',
325
+ 'paint-bucket', 'message-square-more',
326
+ ],
327
+ },
328
+ {
329
+ heading: 'Custom / brand',
330
+ icons: ['ask-arbor', 'google'],
331
+ },
332
+ ];
333
+
334
+ /**
335
+ * The Arbor-curated names, used for the Controls select. The `name` prop accepts every Lucide icon
336
+ * too, but ~1,900 options in a dropdown is not a usable control — use the Icon search story to
337
+ * explore the full set.
338
+ */
339
+ const CURATED_ICON_NAMES: IconName[] = ICON_CATEGORIES.flatMap(category => category.icons);
340
+
246
341
  // ---------------------------------------------------------------------------
247
342
  // Meta
248
343
  // ---------------------------------------------------------------------------
@@ -260,12 +355,12 @@ const meta = {
260
355
  argTypes: {
261
356
  name: {
262
357
  control: 'select',
263
- options: Object.keys(allowedIcons),
358
+ options: CURATED_ICON_NAMES,
264
359
  description: [
265
360
  'Kebab-case key identifying the icon to render. Required.',
266
- 'Maps to an entry in `allowedIcons` — TypeScript errors on any unknown value.',
267
- 'There are currently 106 valid options covering navigation, actions, status,',
268
- 'content, data, people, and system contexts.',
361
+ 'Every [Lucide](https://lucide.dev) icon name is valid, plus Arbor\'s own aliases and custom',
362
+ 'icons — TypeScript errors on any unknown value. The dropdown below lists the Arbor-curated',
363
+ 'names only; use the **Icon search** story to browse the full set.',
269
364
  'Commonly used: `pencil` (edit), `trash` (delete), `plus` (add), `x` (close/remove),',
270
365
  '`check` (confirm), `3-dot` (more actions), `chevron-down` (expand), `triangle-alert` (warning).',
271
366
  ].join(' '),
@@ -739,86 +834,39 @@ const CustomSolidVariantsTemplate = () => (
739
834
  // IconGallery — all icons grouped by semantic category
740
835
  // ---------------------------------------------------------------------------
741
836
 
742
- type IconCategory = {
743
- heading: string;
744
- icons: Array<keyof typeof allowedIcons>;
745
- };
837
+ const ICON_CARD_GRID_STYLE = {
838
+ display: 'grid',
839
+ gridTemplateColumns: 'repeat(auto-fill, minmax(7rem, 1fr))',
840
+ gap: 'var(--spacing-small)',
841
+ } as const;
746
842
 
747
- const ICON_CATEGORIES: IconCategory[] = [
748
- {
749
- heading: 'Navigation',
750
- icons: [
751
- 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right',
752
- 'arrow-right-from-line', 'arrow-up-right',
753
- 'chevron-up', 'chevron-down', 'chevron-left', 'chevron-right',
754
- 'chevrons-left', 'chevrons-right',
755
- 'corner-down-left', 'menu',
756
- 'ellipsis', 'ellipsis-vertical', '3-dot', 'grab',
757
- ],
758
- },
759
- {
760
- heading: 'Actions',
761
- icons: [
762
- 'pencil', 'trash', 'save', 'copy', 'download', 'upload',
763
- 'share', 'send', 'redo', 'undo', 'plus', 'minus',
764
- 'circle-plus', 'iteration-ccw', 'switch-camera',
765
- ],
766
- },
767
- {
768
- heading: 'Status / severity',
769
- icons: [
770
- 'info', 'circle-alert', 'triangle-alert',
771
- 'circle-check', 'circle-check-big', 'circle-x',
772
- 'circle-help', 'check', 'check-solid', 'x', 'x-solid',
773
- ],
774
- },
775
- {
776
- heading: 'Content / media',
777
- icons: [
778
- 'file', 'files', 'clipboard', 'clipboard-list',
779
- 'book-open', 'camera', 'mic', 'paperclip',
780
- 'sheet', 'table',
781
- 'sparkles', 'party-popper', 'lightbulb',
782
- 'favourite-outline', 'favourite-filled',
783
- ],
784
- },
785
- {
786
- heading: 'Charts / data',
787
- icons: [
788
- 'chart-column-increasing', 'chart-spline',
789
- 'trending-up', 'trending-down',
790
- 'percent', 'circle-percent',
791
- 'sorting', 'funnel', 'sliders-horizontal', 'list-filter-plus',
792
- ],
793
- },
794
- {
795
- heading: 'People / org',
796
- icons: [
797
- 'user', 'guardians', 'group', 'staff', 'graduation-cap',
798
- ],
799
- },
800
- {
801
- heading: 'System / state',
802
- icons: [
803
- 'eye', 'eye-off', 'lock', 'lock-open', 'loader',
804
- 'pin', 'flag', 'house', 'history', 'clock-3', 'date',
805
- 'settings', 'expand', 'shrink', 'link', 'external-link',
806
- 'log-out', 'search', 'mail', 'phone', 'smartphone',
807
- 'monitor', 'projector', 'archive',
808
- ],
809
- },
810
- {
811
- heading: 'Other / misc',
812
- icons: [
813
- 'dot', 'layout-list', 'list', 'grid-3x3',
814
- 'paint-bucket', 'message-square-more',
815
- ],
816
- },
817
- {
818
- heading: 'Custom / brand',
819
- icons: ['ask-arbor', 'google'],
820
- },
821
- ];
843
+ const IconCard = ({ name }: { name: IconName }) => (
844
+ <div
845
+ style={{
846
+ display: 'flex',
847
+ flexDirection: 'column',
848
+ alignItems: 'center',
849
+ gap: 'var(--spacing-xsmall)',
850
+ padding: 'var(--spacing-medium)',
851
+ borderRadius: 'var(--border-radius-small)',
852
+ border: '1px solid var(--color-grey-200)',
853
+ background: 'var(--color-grey-100)',
854
+ }}
855
+ >
856
+ <Icon name={name} size={16} />
857
+ <span
858
+ className="ds-text"
859
+ style={{
860
+ color: 'var(--color-grey-600)',
861
+ wordBreak: 'break-word',
862
+ textAlign: 'center',
863
+ fontSize: 'var(--font-size-1-11)',
864
+ }}
865
+ >
866
+ {name}
867
+ </span>
868
+ </div>
869
+ );
822
870
 
823
871
  const IconGalleryTemplate = () => (
824
872
  <div
@@ -843,40 +891,9 @@ const IconGalleryTemplate = () => (
843
891
  >
844
892
  {category.heading}
845
893
  </p>
846
- <div
847
- style={{
848
- display: 'grid',
849
- gridTemplateColumns: 'repeat(auto-fill, minmax(7rem, 1fr))',
850
- gap: 'var(--spacing-small)',
851
- }}
852
- >
894
+ <div style={ICON_CARD_GRID_STYLE}>
853
895
  {category.icons.map(name => (
854
- <div
855
- key={name}
856
- style={{
857
- display: 'flex',
858
- flexDirection: 'column',
859
- alignItems: 'center',
860
- gap: 'var(--spacing-xsmall)',
861
- padding: 'var(--spacing-medium)',
862
- borderRadius: 'var(--border-radius-small)',
863
- border: '1px solid var(--color-grey-200)',
864
- background: 'var(--color-grey-100)',
865
- }}
866
- >
867
- <Icon name={name} size={16} />
868
- <span
869
- className="ds-text"
870
- style={{
871
- color: 'var(--color-grey-600)',
872
- wordBreak: 'break-word',
873
- textAlign: 'center',
874
- fontSize: 'var(--font-size-1-11)',
875
- }}
876
- >
877
- {name}
878
- </span>
879
- </div>
896
+ <IconCard key={name} name={name} />
880
897
  ))}
881
898
  </div>
882
899
  </div>
@@ -884,6 +901,83 @@ const IconGalleryTemplate = () => (
884
901
  </div>
885
902
  );
886
903
 
904
+ // ---------------------------------------------------------------------------
905
+ // IconSearch — search the whole Lucide set without rendering ~1,900 SVGs at once
906
+ // ---------------------------------------------------------------------------
907
+
908
+ const SEARCH_RESULT_LIMIT = 120;
909
+
910
+ const IconSearchTemplate = () => {
911
+ const [query, setQuery] = useState('');
912
+ const searchTerm = query.trim().toLowerCase();
913
+ const matches = useMemo(
914
+ () => (searchTerm
915
+ ? iconNames.filter(name => name.includes(searchTerm))
916
+ : CURATED_ICON_NAMES),
917
+ [searchTerm],
918
+ );
919
+ const visible = matches.slice(0, SEARCH_RESULT_LIMIT);
920
+
921
+ return (
922
+ <div style={{ padding: 'var(--spacing-xlarge)', width: '100%', maxWidth: '900px' }}>
923
+ <label
924
+ className="ds-text"
925
+ htmlFor="ds-icon-search"
926
+ style={{
927
+ display: 'block',
928
+ marginBottom: 'var(--spacing-xsmall)',
929
+ fontWeight: 'var(--font-weight-semi-bold)',
930
+ }}
931
+ >
932
+ Search
933
+ {' '}
934
+ {iconNames.length}
935
+ {' '}
936
+ icon names
937
+ </label>
938
+ <input
939
+ id="ds-icon-search"
940
+ type="search"
941
+ value={query}
942
+ onChange={event => setQuery(event.target.value)}
943
+ placeholder="e.g. calendar, arrow, user"
944
+ style={{
945
+ width: '100%',
946
+ padding: 'var(--spacing-small)',
947
+ marginBottom: 'var(--spacing-small)',
948
+ borderRadius: 'var(--border-radius-small)',
949
+ border: '1px solid var(--color-grey-300)',
950
+ }}
951
+ />
952
+ <p
953
+ className="ds-text"
954
+ style={{ margin: '0 0 var(--spacing-large) 0', color: 'var(--color-grey-600)' }}
955
+ >
956
+ {searchTerm
957
+ ? `${matches.length} match${matches.length === 1 ? '' : 'es'}${
958
+ matches.length > visible.length ? ` — showing the first ${visible.length}` : ''
959
+ }`
960
+ : `Showing the ${visible.length} Arbor-curated names — type to search the full set.`}
961
+ </p>
962
+ <div style={ICON_CARD_GRID_STYLE}>
963
+ {visible.map(name => (
964
+ <IconCard key={name} name={name} />
965
+ ))}
966
+ </div>
967
+ {searchTerm && matches.length === 0 && (
968
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)' }}>
969
+ No icon name contains &ldquo;
970
+ {searchTerm}
971
+ &rdquo;. Browse the full set at
972
+ {' '}
973
+ <a href="https://lucide.dev/icons" target="_blank" rel="noreferrer">lucide.dev/icons</a>
974
+ .
975
+ </p>
976
+ )}
977
+ </div>
978
+ );
979
+ };
980
+
887
981
  const InButtonContextTemplate = () => (
888
982
  <div
889
983
  style={{
@@ -1461,3 +1555,14 @@ export default InEmptyStateContextExample;
1461
1555
  'full-area statements like this one.',
1462
1556
  ].join(' '),
1463
1557
  );
1558
+
1559
+ export const IconSearch: Story = withDescription(
1560
+ {
1561
+ render: () => <IconSearchTemplate />,
1562
+ parameters: { layout: 'fullscreen' },
1563
+ },
1564
+ [
1565
+ 'Every Lucide icon name is a valid `name`, which is far too many to render at once — search',
1566
+ 'instead. Icons load on demand, so only the ones you actually render are downloaded.',
1567
+ ].join(' '),
1568
+ );