@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
@@ -0,0 +1,94 @@
1
+ import { useEffect, useReducer } from 'react';
2
+ import { dynamicIconImports, iconNames } from 'lucide-react/dynamic.mjs';
3
+ import type { DynamicIconModule, IconName } from 'lucide-react/dynamic';
4
+ import type { CustomIconProps } from './types.js';
5
+
6
+ /** Every icon name published by `lucide-react`, kebab-cased. */
7
+ export type LucideIconName = IconName;
8
+
9
+ /** Every `lucide-react` icon name, for building galleries and validating input. */
10
+ export const lucideIconNames: readonly LucideIconName[] = iconNames;
11
+
12
+ type IconLoader = () => Promise<DynamicIconModule>;
13
+ type LucideIconComponent = DynamicIconModule['default'];
14
+
15
+ const iconLoaders: Readonly<Record<LucideIconName, IconLoader>> = dynamicIconImports;
16
+
17
+ /**
18
+ * Icons resolve through `lucide-react`'s per-icon dynamic imports so that consumers only ship the
19
+ * icons they actually render, rather than all ~1,900 of them. Both caches are module-scoped: the
20
+ * first render of a given icon anywhere in the app pays the import, and every render after that —
21
+ * including new instances in other trees — resolves synchronously from `loadedIcons`.
22
+ */
23
+ const loadedIcons = new Map<LucideIconName, LucideIconComponent>();
24
+ const inFlightLoads = new Map<LucideIconName, Promise<void>>();
25
+
26
+ const loadIcon = (name: LucideIconName): Promise<void> => {
27
+ const inFlight = inFlightLoads.get(name);
28
+ if (inFlight) {
29
+ return inFlight;
30
+ }
31
+ // Annotated rather than inferred: names reaching `Icon` from a backend are not always valid.
32
+ const loader: IconLoader | undefined = iconLoaders[name];
33
+ if (!loader) {
34
+ console.error(`[design-system] "${name}" is not a known icon name.`);
35
+ // Cached so an unknown name is only reported once, however many times it is rendered.
36
+ const noSuchIcon = Promise.resolve();
37
+ inFlightLoads.set(name, noSuchIcon);
38
+ return noSuchIcon;
39
+ }
40
+ const load = loader()
41
+ .then((iconModule) => {
42
+ loadedIcons.set(name, iconModule.default);
43
+ })
44
+ .catch((error: unknown) => {
45
+ // Drop the rejected promise so a later render can retry (e.g. after a transient chunk 404).
46
+ inFlightLoads.delete(name);
47
+ console.error(`[design-system] Could not load the "${name}" icon.`, error);
48
+ });
49
+ inFlightLoads.set(name, load);
50
+ return load;
51
+ };
52
+
53
+ const useLucideIcon = (name: LucideIconName): LucideIconComponent | undefined => {
54
+ const [, rerender] = useReducer((renderCount: number) => renderCount + 1, 0);
55
+
56
+ useEffect(() => {
57
+ if (loadedIcons.has(name)) {
58
+ return;
59
+ }
60
+ let isMounted = true;
61
+ void loadIcon(name).then(() => {
62
+ if (isMounted) {
63
+ rerender();
64
+ }
65
+ });
66
+ return () => {
67
+ isMounted = false;
68
+ };
69
+ }, [name]);
70
+
71
+ return loadedIcons.get(name);
72
+ };
73
+
74
+ export type LucideIconProps = CustomIconProps & {
75
+ name: LucideIconName;
76
+ fill?: string;
77
+ };
78
+
79
+ /**
80
+ * Renders a single `lucide-react` icon, loading it on demand. Until the icon resolves an empty SVG
81
+ * of the same size is rendered in its place, so the surrounding layout does not shift and the
82
+ * element keeps its accessible role throughout.
83
+ */
84
+ export const LucideIcon = (props: LucideIconProps) => {
85
+ const { name, ...iconProps } = props;
86
+ const Component = useLucideIcon(name);
87
+
88
+ if (!Component) {
89
+ const { size, ...placeholderProps } = iconProps;
90
+ return <svg width={size} height={size} viewBox="0 0 24 24" {...placeholderProps} />;
91
+ }
92
+
93
+ return <Component {...iconProps} />;
94
+ };
@@ -1,217 +1,77 @@
1
- import {
2
- Archive,
3
- ArrowDown,
4
- ArrowDownUp,
5
- ArrowLeft,
6
- ArrowRight,
7
- ArrowRightFromLine,
8
- ArrowUp,
9
- ArrowUpRight,
10
- BookOpen,
11
- CalendarFold,
12
- Camera,
13
- ChartColumnIncreasing,
14
- ChartSpline,
15
- Check,
16
- ChevronDown,
17
- ChevronLeft,
18
- ChevronRight,
19
- ChevronsLeft,
20
- ChevronsRight,
21
- ChevronUp,
22
- CircleAlert,
23
- CircleCheck,
24
- CircleCheckBig,
25
- CirclePercent,
26
- CirclePlus,
27
- CircleQuestionMark,
28
- CircleX,
29
- Clipboard,
30
- ClipboardList,
31
- Clock3,
32
- CloudUpload,
33
- Copy,
34
- CornerDownLeft,
35
- Dot,
36
- Download,
37
- Ellipsis,
38
- EllipsisVertical,
39
- Expand,
40
- ExternalLink,
41
- Eye,
42
- EyeOff,
43
- File,
44
- Files,
45
- Flag,
46
- Funnel,
47
- GraduationCap,
48
- Grid3x3,
49
- GripVertical,
50
- History,
51
- House,
52
- Info,
53
- IterationCcw,
54
- LayoutList,
55
- Lightbulb,
56
- Link,
57
- List,
58
- ListFilterPlus,
59
- ListTree,
60
- LoaderCircle,
61
- Lock,
62
- LockOpen,
63
- LogOut, type LucideProps,
64
- Mail,
65
- Menu,
66
- MessageSquareMore,
67
- Mic,
68
- Minimize2,
69
- Minus,
70
- Monitor,
71
- PaintBucket,
72
- Paperclip,
73
- PartyPopper,
74
- Pencil,
75
- Percent,
76
- Phone,
77
- Pin,
78
- Plus,
79
- Projector,
80
- Redo2,
81
- Save,
82
- Search,
83
- Send,
84
- Settings,
85
- Share,
86
- Sheet,
87
- SlidersHorizontal,
88
- Smartphone,
89
- Sparkles,
90
- Star, SwitchCamera, Table, Trash2, TrendingDown, TrendingUp, TriangleAlert, Undo2, Upload, User,
91
- Users,
92
- UsersRound, X, Zap,
93
- } from 'lucide-react';
1
+ import type { ReactNode } from 'react';
2
+ import { LucideIcon, lucideIconNames, type LucideIconName } from './LucideIcon.js';
94
3
  import { AskArbor } from './customIcons/AskArbor.js';
95
4
  import { CheckSolid } from './customIcons/CheckSolid.js';
96
5
  import { Google } from './customIcons/Google.js';
97
6
  import { XSolid } from './customIcons/XSolid.js';
98
7
  import type { CustomIconProps } from './types.js';
99
8
 
100
- export const allowedIcons = {
101
- // lucide icons
102
- '3-dot': EllipsisVertical,
103
- 'archive': Archive,
104
- 'arrow-down': ArrowDown,
105
- 'arrow-left': ArrowLeft,
106
- 'arrow-right-from-line': ArrowRightFromLine,
107
- 'arrow-right': ArrowRight,
108
- 'arrow-up-right': ArrowUpRight,
109
- 'arrow-up': ArrowUp,
110
- 'book-open': BookOpen,
111
- 'camera': Camera,
112
- 'chart-column-increasing': ChartColumnIncreasing,
113
- 'chart-spline': ChartSpline,
114
- 'check': Check,
115
- 'chevron-down': ChevronDown,
116
- 'chevron-left': ChevronLeft,
117
- 'chevron-right': ChevronRight,
118
- 'chevron-up': ChevronUp,
119
- 'chevrons-left': ChevronsLeft,
120
- 'chevrons-right': ChevronsRight,
121
- 'circle-alert': CircleAlert,
122
- 'circle-check-big': CircleCheckBig,
123
- 'circle-check': CircleCheck,
124
- 'circle-help': CircleQuestionMark,
125
- 'circle-percent': CirclePercent,
126
- 'circle-plus': CirclePlus,
127
- 'circle-x': CircleX,
128
- 'clipboard-list': ClipboardList,
129
- 'clipboard': Clipboard,
130
- 'clock-3': Clock3,
131
- 'cloud-upload': CloudUpload,
132
- 'copy': Copy,
133
- 'corner-down-left': CornerDownLeft,
134
- 'date': CalendarFold,
135
- 'dot': Dot,
136
- 'download': Download,
137
- 'ellipsis-vertical': EllipsisVertical,
138
- 'ellipsis': Ellipsis,
139
- 'expand': Expand,
140
- 'external-link': ExternalLink,
141
- 'eye-off': EyeOff,
142
- 'eye': Eye,
143
- 'favourite-outline': Star,
144
- 'file': File,
145
- 'files': Files,
146
- 'flag': Flag,
147
- 'funnel': Funnel,
148
- 'grab': GripVertical,
149
- 'graduation-cap': GraduationCap,
150
- 'grid-3x3': Grid3x3,
151
- 'group': UsersRound,
152
- 'guardians': Users,
153
- 'history': History,
154
- 'house': House,
155
- 'info': Info,
156
- 'iteration-ccw': IterationCcw,
157
- 'layout-list': LayoutList,
158
- 'lightbulb': Lightbulb,
159
- 'link': Link,
160
- 'list-filter-plus': ListFilterPlus,
161
- 'list': List,
162
- 'list-tree': ListTree,
163
- 'loader': LoaderCircle,
164
- 'lock-open': LockOpen,
165
- 'lock': Lock,
166
- 'log-out': LogOut,
167
- 'mail': Mail,
168
- 'menu': Menu,
169
- 'message-square-more': MessageSquareMore,
170
- 'mic': Mic,
171
- 'minus': Minus,
172
- 'monitor': Monitor,
173
- 'paint-bucket': PaintBucket,
174
- 'paperclip': Paperclip,
175
- 'party-popper': PartyPopper,
176
- 'pencil': Pencil,
177
- 'percent': Percent,
178
- 'phone': Phone,
179
- 'pin': Pin,
180
- 'plus': Plus,
181
- 'projector': Projector,
182
- 'redo': Redo2,
183
- 'save': Save,
184
- 'search': Search,
185
- 'send': Send,
186
- 'settings': Settings,
187
- 'share': Share,
188
- 'sheet': Sheet,
189
- 'shrink': Minimize2,
190
- 'sliders-horizontal': SlidersHorizontal,
191
- 'smartphone': Smartphone,
192
- 'sorting': ArrowDownUp,
193
- 'sparkles': Sparkles,
194
- 'staff': GraduationCap,
195
- 'switch-camera': SwitchCamera,
196
- 'table': Table,
197
- 'trash': Trash2,
198
- 'trending-down': TrendingDown,
199
- 'trending-up': TrendingUp,
200
- 'triangle-alert': TriangleAlert,
201
- 'undo': Undo2,
202
- 'upload': Upload,
203
- 'user': User,
204
- 'x': X,
205
- 'zap': Zap,
206
- // custom icons
9
+ /**
10
+ * Arbor names that point at a differently-named Lucide icon. These take precedence over the Lucide
11
+ * name they shadow, so `trash` stays the lidded can (`trash-2`) rather than Lucide's plain `trash`.
12
+ * Seven of these — `grab`, `group`, `loader`, `redo`, `shrink`, `trash` and `undo` — shadow a real
13
+ * Lucide icon with a different glyph, which is deliberate: renaming them would break every existing
14
+ * consumer. Reach for the target name directly if you want Lucide's version.
15
+ *
16
+ * The `satisfies` clause makes every target a compile-time-checked Lucide icon name.
17
+ */
18
+ export const iconAliases = {
19
+ '3-dot': 'ellipsis-vertical',
20
+ 'circle-help': 'circle-question-mark',
21
+ 'date': 'calendar-fold',
22
+ 'favourite-outline': 'star',
23
+ 'grab': 'grip-vertical',
24
+ 'group': 'users-round',
25
+ 'guardians': 'users',
26
+ 'loader': 'loader-circle',
27
+ 'redo': 'redo-2',
28
+ 'shrink': 'minimize-2',
29
+ 'sorting': 'arrow-down-up',
30
+ 'staff': 'graduation-cap',
31
+ 'trash': 'trash-2',
32
+ 'undo': 'undo-2',
33
+ } as const satisfies Readonly<Record<string, LucideIconName>>;
34
+
35
+ /**
36
+ * Icons that are not plain Lucide glyphs: Arbor-specific artwork, and the solid variants built for
37
+ * particular semantic emphasis states.
38
+ */
39
+ export const customIcons = {
207
40
  'ask-arbor': AskArbor,
208
41
  'google': Google,
209
- // solid icons
210
- 'check-solid': (props: CustomIconProps) => <CheckSolid {...props} />,
211
- 'favourite-filled': (props: LucideProps) => (
212
- <Star {...props} fill={props.color} />
42
+ 'check-solid': CheckSolid,
43
+ 'favourite-filled': (props: CustomIconProps) => (
44
+ <LucideIcon name="star" {...props} fill={props.color} />
213
45
  ),
214
- 'x-solid': (props: CustomIconProps) => <XSolid {...props} />,
215
- } as const;
46
+ 'x-solid': XSolid,
47
+ } as const satisfies Readonly<Record<string, (props: CustomIconProps) => ReactNode>>;
48
+
49
+ export type IconName
50
+ = | LucideIconName
51
+ | keyof typeof iconAliases
52
+ | keyof typeof customIcons;
53
+
54
+ // `Object.keys` widens to `string[]`, so the key unions are reasserted here.
55
+ const aliasNames = Object.keys(iconAliases) as ReadonlyArray<keyof typeof iconAliases>;
56
+ const customIconNames = Object.keys(customIcons) as ReadonlyArray<keyof typeof customIcons>;
57
+
58
+ /** Every name `Icon` accepts: the whole Lucide set, plus Arbor's aliases and custom icons. */
59
+ export const iconNames: readonly IconName[] = [
60
+ ...lucideIconNames,
61
+ ...aliasNames,
62
+ ...customIconNames,
63
+ ];
64
+
65
+ /**
66
+ * The `Icon` name allow-list, as a plain object so that membership can be tested with
67
+ * `hasOwnProperty`. Prefer {@link isIconName}; the values carry no meaning and exist only to make
68
+ * this a record. Icon components are no longer held here — they are loaded on demand, so nothing
69
+ * outside a render has a component to hand out.
70
+ */
71
+ export const allowedIcons = Object.freeze(
72
+ Object.fromEntries(iconNames.map(name => [name, true])),
73
+ ) as Readonly<Record<IconName, true>>;
216
74
 
217
- export type IconName = keyof typeof allowedIcons;
75
+ /** Narrows an arbitrary string to an `IconName`, for validating names that come from a backend. */
76
+ export const isIconName = (name: string): name is IconName =>
77
+ Object.prototype.hasOwnProperty.call(allowedIcons, name);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `lucide-react` ships its dynamic-import entry point as `dynamic.mjs` and declares no `exports`
3
+ * map, so the extensionless `lucide-react/dynamic` specifier only resolves in bundlers that list
4
+ * `.mjs` in `resolve.extensions`. `arbor-fe-library`'s webpack config does not, so we import the
5
+ * file by its exact name instead. TypeScript then looks for a sibling `dynamic.d.mts`, which the
6
+ * package does not publish — this declaration points it at the types that do ship.
7
+ *
8
+ * Type-only imports still use `lucide-react/dynamic`, which resolves under both `bundler` and
9
+ * classic `node` module resolution and is erased before any bundler sees it.
10
+ */
11
+ declare module 'lucide-react/dynamic.mjs' {
12
+ export * from 'lucide-react/dynamic';
13
+ }
@@ -2,8 +2,9 @@ export const iconSizes = [12, 16, 24] as const;
2
2
  export type IconSize = typeof iconSizes[number];
3
3
 
4
4
  export type CustomIconProps = {
5
- size: IconSize;
6
- color?: string;
7
- role?: string;
8
- className?: string;
5
+ 'size': IconSize;
6
+ 'color'?: string;
7
+ 'role'?: string;
8
+ 'className'?: string;
9
+ 'aria-hidden'?: boolean;
9
10
  };
@@ -107,7 +107,7 @@ const DEVELOPER_NOTES = [
107
107
  '| `active` | `boolean` | `false` | Marks this tab as selected — sets `aria-selected` and active styles |',
108
108
  '| `tabElement` | `"button" \\| "link"` | `"button"` | Inner element type. `"button"` for JS-controlled tabs; `"link"` for anchor/router navigation |',
109
109
  '| `tabElementProps` | `ButtonHTMLAttributes` or `AnchorHTMLAttributes` | — | Props forwarded to the inner `<button>` or `<a>`. Use `onClick` for state control; `href` for links |',
110
- '| `iconName` | `keyof allowedIcons` | — | Optional icon rendered before the label text (16 px) |',
110
+ '| `iconName` | `IconName` | — | Optional icon rendered before the label text (16 px) |',
111
111
  '| `children` | `ReactNode` | — | Tab label text |',
112
112
  '| `className` | `string` | — | Extra CSS class on the `<li>` wrapper |',
113
113
  '',
@@ -9,7 +9,7 @@ import {
9
9
  } from '@storybook/addon-docs/blocks';
10
10
  import { TabsItem } from './TabsItem.js';
11
11
  import { Tabs } from './Tabs.js';
12
- import { allowedIcons } from 'Components/icon/allowedIcons';
12
+ import type { IconName } from 'Components/icon/allowedIcons';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Docs page
@@ -93,10 +93,14 @@ const meta = {
93
93
  },
94
94
  iconName: {
95
95
  control: 'select',
96
- options: Object.keys(allowedIcons),
97
- description: 'Optional icon rendered before the label text at 16 px.',
96
+ // Any Icon name works; these are the ones that read well in a tab.
97
+ options: [
98
+ 'house', 'user', 'guardians', 'staff', 'date', 'sheet', 'table', 'settings',
99
+ 'chart-column-increasing', 'clipboard-list', 'mail', 'archive',
100
+ ] satisfies IconName[],
101
+ description: 'Optional icon rendered before the label text at 16 px. Accepts any `IconName`.',
98
102
  table: {
99
- type: { summary: 'keyof allowedIcons' },
103
+ type: { summary: 'IconName' },
100
104
  defaultValue: { summary: 'undefined' },
101
105
  },
102
106
  },
@@ -1,11 +1,11 @@
1
1
  import classNames from 'classnames';
2
- import type { allowedIcons } from 'Components/icon/allowedIcons';
2
+ import type { IconName } from 'Components/icon/allowedIcons';
3
3
  import { Icon } from 'Components/icon/Icon';
4
4
  import type { AnchorHTMLAttributes, ButtonHTMLAttributes, HTMLAttributes } from 'react';
5
5
 
6
6
  type TabsItemProps = {
7
7
  active?: boolean;
8
- iconName?: keyof typeof allowedIcons;
8
+ iconName?: IconName;
9
9
  } & (
10
10
  | { tabElement?: 'button'; tabElementProps?: ButtonHTMLAttributes<HTMLButtonElement> }
11
11
  | { tabElement?: 'link'; tabElementProps?: AnchorHTMLAttributes<HTMLAnchorElement> }