@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.
- package/.github/pull_request_template.md +4 -3
- package/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +1 -0
- package/dist/components/formField/FormField.d.ts +34 -18
- package/dist/components/formField/FormField.d.ts.map +1 -1
- package/dist/components/formField/FormField.js +5 -1
- package/dist/components/formField/FormField.js.map +1 -1
- package/dist/components/formField/FormField.stories.d.ts +4 -0
- package/dist/components/formField/FormField.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +314 -7
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +66 -0
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/formField/inputs/checkbox/CheckboxGroup.d.ts +1 -0
- package/dist/components/formField/inputs/checkbox/CheckboxGroup.d.ts.map +1 -1
- package/dist/components/formField/inputs/checkbox/CheckboxGroup.js +2 -2
- package/dist/components/formField/inputs/checkbox/CheckboxGroup.js.map +1 -1
- package/dist/components/formField/inputs/checkbox/CheckboxInput.d.ts +1 -0
- package/dist/components/formField/inputs/checkbox/CheckboxInput.d.ts.map +1 -1
- package/dist/components/formField/inputs/checkbox/CheckboxInput.js +2 -2
- package/dist/components/formField/inputs/checkbox/CheckboxInput.js.map +1 -1
- package/dist/components/formField/inputs/radio/RadioButtonGroup.d.ts +1 -0
- package/dist/components/formField/inputs/radio/RadioButtonGroup.d.ts.map +1 -1
- package/dist/components/formField/inputs/radio/RadioButtonGroup.js +2 -2
- package/dist/components/formField/inputs/radio/RadioButtonGroup.js.map +1 -1
- package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.d.ts +2 -2
- package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.d.ts.map +1 -1
- package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.js +0 -1
- package/dist/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.d.ts +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +1 -1
- package/dist/components/icon/Icon.d.ts.map +1 -1
- package/dist/components/icon/Icon.js +18 -3
- package/dist/components/icon/Icon.js.map +1 -1
- package/dist/components/icon/Icon.stories.d.ts +4 -2
- package/dist/components/icon/Icon.stories.d.ts.map +1 -1
- package/dist/components/icon/Icon.stories.js +150 -102
- package/dist/components/icon/Icon.stories.js.map +1 -1
- package/dist/components/icon/Icon.test.js +146 -6
- package/dist/components/icon/Icon.test.js.map +1 -1
- package/dist/components/icon/LucideIcon.d.ts +17 -0
- package/dist/components/icon/LucideIcon.d.ts.map +1 -0
- package/dist/components/icon/LucideIcon.js +73 -0
- package/dist/components/icon/LucideIcon.js.map +1 -0
- package/dist/components/icon/allowedIcons.d.ts +44 -108
- package/dist/components/icon/allowedIcons.d.ts.map +1 -1
- package/dist/components/icon/allowedIcons.js +52 -112
- package/dist/components/icon/allowedIcons.js.map +1 -1
- package/dist/components/icon/types.d.ts +5 -4
- package/dist/components/icon/types.d.ts.map +1 -1
- package/dist/components/tabs/Tabs.d.ts +1 -1
- package/dist/components/tabs/Tabs.stories.d.ts +1 -1
- package/dist/components/tabs/Tabs.stories.js +1 -1
- package/dist/components/tabs/Tabs.stories.js.map +1 -1
- package/dist/components/tabs/TabsItem.d.ts +2 -2
- package/dist/components/tabs/TabsItem.d.ts.map +1 -1
- package/dist/components/tabs/TabsItem.stories.d.ts +5 -5
- package/dist/components/tabs/TabsItem.stories.d.ts.map +1 -1
- package/dist/components/tabs/TabsItem.stories.js +7 -4
- package/dist/components/tabs/TabsItem.stories.js.map +1 -1
- package/dist/components/toggle/Toggle.d.ts +7 -1
- package/dist/components/toggle/Toggle.d.ts.map +1 -1
- package/dist/components/toggle/Toggle.js +2 -2
- package/dist/components/toggle/Toggle.js.map +1 -1
- package/dist/components/toggle/Toggle.stories.d.ts +1 -1
- package/dist/index.css +21 -0
- package/dist/index.css.map +1 -1
- package/package.json +1 -1
- package/src/components/formField/FormField.stories.tsx +398 -7
- package/src/components/formField/FormField.test.tsx +100 -0
- package/src/components/formField/FormField.tsx +46 -27
- package/src/components/formField/inputs/checkbox/CheckboxGroup.tsx +3 -2
- package/src/components/formField/inputs/checkbox/CheckboxInput.tsx +3 -0
- package/src/components/formField/inputs/checkbox/checkboxInput.scss +4 -0
- package/src/components/formField/inputs/radio/RadioButtonGroup.tsx +11 -1
- package/src/components/formField/inputs/radio/radioButtonInput.scss +8 -0
- package/src/components/formField/inputs/selectDropdown/items/item/SelectDropdownItem.tsx +2 -2
- package/src/components/icon/Icon.stories.tsx +224 -119
- package/src/components/icon/Icon.test.tsx +178 -6
- package/src/components/icon/Icon.tsx +25 -3
- package/src/components/icon/LucideIcon.tsx +94 -0
- package/src/components/icon/allowedIcons.tsx +67 -207
- package/src/components/icon/lucideDynamic.d.ts +13 -0
- package/src/components/icon/types.ts +5 -4
- package/src/components/tabs/Tabs.stories.tsx +1 -1
- package/src/components/tabs/TabsItem.stories.tsx +8 -4
- package/src/components/tabs/TabsItem.tsx +2 -2
- package/src/components/toggle/Toggle.tsx +13 -3
- package/src/components/toggle/toggle.scss +22 -0
|
@@ -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 {
|
|
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
|
-
|
|
9
|
-
test(
|
|
10
|
-
|
|
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 {
|
|
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
|
-
<
|
|
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
|
)}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
'
|
|
111
|
-
'
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
122
|
-
'
|
|
123
|
-
'
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
210
|
-
'
|
|
211
|
-
|
|
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':
|
|
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
|
-
|
|
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` | `
|
|
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
|
'',
|