@arbor-education/design-system.components 3.1.0 → 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 (50) hide show
  1. package/.github/pull_request_template.md +4 -3
  2. package/CHANGELOG.md +10 -0
  3. package/CONTRIBUTING.md +1 -0
  4. package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.d.ts +2 -2
  5. package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.d.ts.map +1 -1
  6. package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.js +0 -1
  7. package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.js.map +1 -1
  8. package/dist/components/formField/inputs/time/TimeInput.d.ts +1 -1
  9. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +1 -1
  10. package/dist/components/icon/Icon.d.ts.map +1 -1
  11. package/dist/components/icon/Icon.js +18 -3
  12. package/dist/components/icon/Icon.js.map +1 -1
  13. package/dist/components/icon/Icon.stories.d.ts +4 -2
  14. package/dist/components/icon/Icon.stories.d.ts.map +1 -1
  15. package/dist/components/icon/Icon.stories.js +150 -102
  16. package/dist/components/icon/Icon.stories.js.map +1 -1
  17. package/dist/components/icon/Icon.test.js +146 -6
  18. package/dist/components/icon/Icon.test.js.map +1 -1
  19. package/dist/components/icon/LucideIcon.d.ts +17 -0
  20. package/dist/components/icon/LucideIcon.d.ts.map +1 -0
  21. package/dist/components/icon/LucideIcon.js +73 -0
  22. package/dist/components/icon/LucideIcon.js.map +1 -0
  23. package/dist/components/icon/allowedIcons.d.ts +44 -108
  24. package/dist/components/icon/allowedIcons.d.ts.map +1 -1
  25. package/dist/components/icon/allowedIcons.js +52 -112
  26. package/dist/components/icon/allowedIcons.js.map +1 -1
  27. package/dist/components/icon/types.d.ts +5 -4
  28. package/dist/components/icon/types.d.ts.map +1 -1
  29. package/dist/components/tabs/Tabs.d.ts +1 -1
  30. package/dist/components/tabs/Tabs.stories.d.ts +1 -1
  31. package/dist/components/tabs/Tabs.stories.js +1 -1
  32. package/dist/components/tabs/Tabs.stories.js.map +1 -1
  33. package/dist/components/tabs/TabsItem.d.ts +2 -2
  34. package/dist/components/tabs/TabsItem.d.ts.map +1 -1
  35. package/dist/components/tabs/TabsItem.stories.d.ts +5 -5
  36. package/dist/components/tabs/TabsItem.stories.d.ts.map +1 -1
  37. package/dist/components/tabs/TabsItem.stories.js +7 -4
  38. package/dist/components/tabs/TabsItem.stories.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.tsx +2 -2
  41. package/src/components/icon/Icon.stories.tsx +224 -119
  42. package/src/components/icon/Icon.test.tsx +178 -6
  43. package/src/components/icon/Icon.tsx +25 -3
  44. package/src/components/icon/LucideIcon.tsx +94 -0
  45. package/src/components/icon/allowedIcons.tsx +67 -207
  46. package/src/components/icon/lucideDynamic.d.ts +13 -0
  47. package/src/components/icon/types.ts +5 -4
  48. package/src/components/tabs/Tabs.stories.tsx +1 -1
  49. package/src/components/tabs/TabsItem.stories.tsx +8 -4
  50. package/src/components/tabs/TabsItem.tsx +2 -2
@@ -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
+ );
@@ -1,14 +1,186 @@
1
- import { describe, expect, test } from 'vitest';
2
- import { render, screen } from '@testing-library/react';
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import { Icon } from './Icon.js';
4
4
  import '@testing-library/jest-dom/vitest';
5
- import { allowedIcons, type IconName } from './allowedIcons.js';
5
+ import {
6
+ allowedIcons,
7
+ customIcons,
8
+ iconAliases,
9
+ isIconName,
10
+ type IconName,
11
+ } from './allowedIcons.js';
12
+ import { lucideIconNames } from './LucideIcon.js';
13
+
14
+ /**
15
+ * Every name the design system supported before the whole Lucide set was exposed. Kept as a literal
16
+ * list so that removing a name from `iconAliases`, or Lucide dropping one, fails here rather than
17
+ * in a consumer.
18
+ */
19
+ const PREVIOUSLY_SUPPORTED_NAMES = [
20
+ '3-dot', 'archive', 'arrow-down', 'arrow-left', 'arrow-right-from-line', 'arrow-right',
21
+ 'arrow-up-right', 'arrow-up', 'ask-arbor', 'book-open', 'camera', 'chart-column-increasing',
22
+ 'chart-spline', 'check', 'check-solid', 'chevron-down', 'chevron-left', 'chevron-right',
23
+ 'chevron-up', 'chevrons-left', 'chevrons-right', 'circle-alert', 'circle-check-big',
24
+ 'circle-check', 'circle-help', 'circle-percent', 'circle-plus', 'circle-x', 'clipboard-list',
25
+ 'clipboard', 'clock-3', 'cloud-upload', 'copy', 'corner-down-left', 'date', 'dot', 'download',
26
+ 'ellipsis-vertical', 'ellipsis', 'expand', 'external-link', 'eye-off', 'eye', 'favourite-filled',
27
+ 'favourite-outline', 'file', 'files', 'flag', 'funnel', 'google', 'grab', 'graduation-cap',
28
+ 'grid-3x3', 'group', 'guardians', 'history', 'house', 'info', 'iteration-ccw', 'layout-list',
29
+ 'lightbulb', 'link', 'list-filter-plus', 'list', 'list-tree', 'loader', 'lock-open', 'lock',
30
+ 'log-out', 'mail', 'menu', 'message-square-more', 'mic', 'minus', 'monitor', 'paint-bucket',
31
+ 'paperclip', 'party-popper', 'pencil', 'percent', 'phone', 'pin', 'plus', 'projector', 'redo',
32
+ 'save', 'search', 'send', 'settings', 'share', 'sheet', 'shrink', 'sliders-horizontal',
33
+ 'smartphone', 'sorting', 'sparkles', 'staff', 'switch-camera', 'table', 'trash', 'trending-down',
34
+ 'trending-up', 'triangle-alert', 'undo', 'upload', 'user', 'x', 'x-solid', 'zap',
35
+ ] as const satisfies readonly IconName[];
36
+
37
+ /**
38
+ * A spread of plain Lucide names rather than all ~1,900 — enough to cover the naming shapes that
39
+ * kebab-casing tends to break on (leading single letters, digits, repeated digits, letter pairs)
40
+ * plus Lucide's own deprecated aliases.
41
+ */
42
+ const SAMPLED_LUCIDE_NAMES = [
43
+ 'ban', 'shopping-cart', 'a-arrow-down', 'a-large-small', 'align-center', 'arrow-down-a-z',
44
+ 'arrow-down-0-1', 'axis-3d', 'bluetooth-connected', 'building-2', 'circle-slash-2', 'clock-12',
45
+ 'columns-3', 'file-json-2', 'git-pull-request-arrow', 'square-dashed-mouse-pointer', 'test-tube',
46
+ 'volume-2', 'wifi-off', 'zoom-out',
47
+ // Lucide's deprecated aliases still resolve, so they are supported too
48
+ 'sort-desc', 'area-chart',
49
+ ] as const satisfies readonly IconName[];
50
+
51
+ /**
52
+ * Waits for the loaded glyph rather than the placeholder. Both carry `role="img"`, so they are told
53
+ * apart by their contents: the placeholder is an empty SVG until the icon's chunk resolves.
54
+ */
55
+ const findLoadedIcon = async () => waitFor(() => {
56
+ const icon = screen.getByRole('img', { hidden: true });
57
+ expect(icon.childElementCount).toBeGreaterThan(0);
58
+ return icon;
59
+ });
60
+
61
+ const renderedIcon = async (name: IconName) => {
62
+ const { unmount } = render(<Icon name={name} />);
63
+ const icon = await findLoadedIcon();
64
+ const classes = icon.getAttribute('class') ?? '';
65
+ unmount();
66
+ return { tagName: icon.tagName.toLowerCase(), classes };
67
+ };
6
68
 
7
69
  describe('Icon', () => {
8
- Object.keys(allowedIcons).forEach((icon) => {
9
- test(`Icon ${icon} renders`, () => {
10
- render(<Icon name={icon as IconName} />);
70
+ describe('name allow-list', () => {
71
+ test('every previously supported name is still accepted', () => {
72
+ const rejected = PREVIOUSLY_SUPPORTED_NAMES.filter(name => !isIconName(name));
73
+ expect(rejected).toEqual([]);
74
+ });
75
+
76
+ test('the whole Lucide set is accepted', () => {
77
+ const rejected = lucideIconNames.filter(name => !isIconName(name));
78
+ expect(rejected).toEqual([]);
79
+ expect(lucideIconNames.length).toBeGreaterThan(1500);
80
+ });
81
+
82
+ test('every alias target is a real Lucide icon', () => {
83
+ const lucideNames = new Set<string>(lucideIconNames);
84
+ const unresolved = Object.entries(iconAliases)
85
+ .filter(([, target]) => !lucideNames.has(target));
86
+ expect(unresolved).toEqual([]);
87
+ });
88
+
89
+ test('isIconName rejects names that are not icons', () => {
90
+ expect(isIconName('behaviour')).toBe(false);
91
+ expect(isIconName('not-an-icon')).toBe(false);
92
+ expect(isIconName('')).toBe(false);
93
+ expect(isIconName('toString')).toBe(false);
94
+ });
95
+
96
+ test('allowedIcons supports hasOwnProperty membership checks', () => {
97
+ // arbor-fe-library validates backend-supplied names this way, so the shape has to hold.
98
+ expect(Object.prototype.hasOwnProperty.call(allowedIcons, 'ban')).toBe(true);
99
+ expect(Object.prototype.hasOwnProperty.call(allowedIcons, 'shopping-cart')).toBe(true);
100
+ expect(Object.prototype.hasOwnProperty.call(allowedIcons, '3-dot')).toBe(true);
101
+ expect(Object.prototype.hasOwnProperty.call(allowedIcons, 'ask-arbor')).toBe(true);
102
+ expect(Object.prototype.hasOwnProperty.call(allowedIcons, 'not-an-icon')).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe('rendering', () => {
107
+ test.each(Object.keys(iconAliases) as Array<keyof typeof iconAliases>)(
108
+ 'alias %s renders its target glyph',
109
+ async (name) => {
110
+ const { tagName, classes } = await renderedIcon(name);
111
+ expect(tagName).toBe('svg');
112
+ // Lucide tags each glyph with its own name, which proves the alias resolved correctly.
113
+ expect(classes.split(' ')).toContain(`lucide-${iconAliases[name]}`);
114
+ expect(classes.split(' ')).toContain(`ds-icon-${name}`);
115
+ },
116
+ );
117
+
118
+ test.each(Object.keys(customIcons) as Array<keyof typeof customIcons>)(
119
+ 'custom icon %s renders',
120
+ async (name) => {
121
+ const { tagName, classes } = await renderedIcon(name);
122
+ expect(tagName).toBe('svg');
123
+ expect(classes.split(' ')).toContain(`ds-icon-${name}`);
124
+ },
125
+ );
126
+
127
+ test.each(SAMPLED_LUCIDE_NAMES)('lucide icon %s renders', async (name) => {
128
+ const { tagName, classes } = await renderedIcon(name);
129
+ expect(tagName).toBe('svg');
130
+ expect(classes.split(' ')).toContain(`ds-icon-${name}`);
131
+ });
132
+
133
+ test('an Arbor alias wins over the Lucide icon it shadows', async () => {
134
+ const { classes } = await renderedIcon('trash');
135
+ expect(classes.split(' ')).toContain('lucide-trash-2');
136
+ expect(classes.split(' ')).not.toContain('lucide-trash');
137
+ });
138
+
139
+ test('favourite-filled fills the star with the icon colour', async () => {
140
+ render(<Icon name="favourite-filled" color="rebeccapurple" />);
141
+ const icon = await findLoadedIcon();
142
+ expect(icon).toHaveAttribute('fill', 'rebeccapurple');
143
+ expect(icon.getAttribute('class')).toContain('lucide-star');
144
+ });
145
+
146
+ test('a placeholder of the right size holds the icon\'s place while it loads', () => {
147
+ // `not-yet-loaded` is only true on the very first render of an icon in this file's module
148
+ // registry, so pick one no other test touches.
149
+ render(<Icon name="microscope" size={24} className="custom-class" />);
150
+ const placeholder = screen.getByRole('img', { hidden: true });
151
+ expect(placeholder).toHaveAttribute('width', '24');
152
+ expect(placeholder).toHaveAttribute('height', '24');
153
+ expect(placeholder).toHaveClass('custom-class', 'ds-icon', 'ds-icon-microscope');
154
+ expect(placeholder).toHaveAttribute('aria-hidden', 'true');
155
+ });
156
+
157
+ test('a loaded icon renders synchronously on later mounts', async () => {
158
+ const { unmount } = render(<Icon name="rocket" />);
159
+ await findLoadedIcon();
160
+ unmount();
161
+
162
+ render(<Icon name="rocket" />);
163
+ // No `await` — the module-level cache means no second placeholder frame.
164
+ expect(screen.getByRole('img', { hidden: true }).getAttribute('class'))
165
+ .toContain('lucide-rocket');
166
+ });
167
+
168
+ test.each([12, 16, 24] as const)('renders at size %i', async (size) => {
169
+ render(<Icon name="ban" size={size} />);
170
+ const icon = await findLoadedIcon();
171
+ expect(icon).toHaveAttribute('width', String(size));
172
+ expect(icon).toHaveAttribute('height', String(size));
173
+ });
174
+
175
+ test('an unrecognised name renders the placeholder and reports the problem', () => {
176
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
177
+ // Consumers passing backend data can defeat the type, so this must not throw.
178
+ render(<Icon name={'definitely-not-an-icon' as IconName} />);
11
179
  expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
180
+ expect(consoleError).toHaveBeenCalledWith(
181
+ '[design-system] "definitely-not-an-icon" is not a known icon name.',
182
+ );
183
+ consoleError.mockRestore();
12
184
  });
13
185
  });
14
186
 
@@ -1,5 +1,6 @@
1
1
  import classNames from 'classnames';
2
- import { allowedIcons, type IconName } from './allowedIcons.js';
2
+ import { customIcons, iconAliases, type IconName } from './allowedIcons.js';
3
+ import { LucideIcon, type LucideIconName } from './LucideIcon.js';
3
4
  import type { CustomIconProps, IconSize } from './types.js';
4
5
 
5
6
  type IconProps = {
@@ -10,14 +11,35 @@ type IconProps = {
10
11
  className?: string;
11
12
  };
12
13
 
14
+ const isCustomIconName = (name: IconName): name is keyof typeof customIcons =>
15
+ Object.prototype.hasOwnProperty.call(customIcons, name);
16
+
17
+ const isAliasedIconName = (name: IconName): name is keyof typeof iconAliases =>
18
+ Object.prototype.hasOwnProperty.call(iconAliases, name);
19
+
20
+ const resolveLucideName = (name: Exclude<IconName, keyof typeof customIcons>): LucideIconName =>
21
+ isAliasedIconName(name) ? iconAliases[name] : name;
22
+
23
+ /**
24
+ * Arbor's custom icons and aliases are resolved before the Lucide set, so an Arbor name always wins
25
+ * over the Lucide icon it shadows.
26
+ */
27
+ const IconGlyph = (props: CustomIconProps & { name: IconName }) => {
28
+ const { name, ...glyphProps } = props;
29
+ if (isCustomIconName(name)) {
30
+ const CustomIcon = customIcons[name];
31
+ return <CustomIcon {...glyphProps} />;
32
+ }
33
+ return <LucideIcon name={resolveLucideName(name)} {...glyphProps} />;
34
+ };
35
+
13
36
  export const Icon = (props: IconProps) => {
14
37
  const { size = 16, color = 'currentColor', name, screenReaderText, className } = props;
15
- const Component = allowedIcons[name];
16
38
  const classes = classNames(className, `ds-icon ds-icon-${name}`);
17
39
  return (
18
40
  <>
19
41
  {/* why aria-hidden? https://gomakethings.com/revisting-aria-label-versus-a-visually-hidden-class/ */}
20
- <Component size={size} color={color} className={classes} role="img" aria-hidden />
42
+ <IconGlyph name={name} size={size} color={color} className={classes} role="img" aria-hidden />
21
43
  {!!screenReaderText && (
22
44
  <span className="sr-only">{screenReaderText}</span>
23
45
  )}