Skip to content

Commit 7475b51

Browse files
HenriqueLimasclaude
andcommitted
feat(evo-react): migrate 1,036 icons from ebayui-core-react
Adds icon system to @evo-web/react with individual subpath exports for optimal tree-shaking and build performance. Key changes: - Generate 1,036 individual EvoIcon components from @eBay/skin - Use package.json wildcard pattern for subpath exports - Create EvoIconProvider context for SSR optimization - Add build script to auto-generate icons from Skin - Configure Vite to build icons as separate entry points - Update EvoButton to use EvoIconChevronDown16 - Export icon utilities from main package entry - Create Storybook documentation with usage examples - Add ADR 0004 documenting hybrid import path strategy Architecture: - Components use unified entry: import { EvoButton } from "@evo-web/react" - Icons use subpaths: import { EvoIconCart16 } from "@evo-web/react/evo-icon-cart-16" - Prevents bundlers from parsing 1,000+ unused icons - SSR-friendly with fallback lookup and symbol registration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b881d57 commit 7475b51

File tree

1,053 files changed

+20557
-16
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

1,053 files changed

+20557
-16
lines changed

.changeset/new-feet-open.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@evo-web/react": patch
3+
---
4+
5+
feat(evo-react): add icon components
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# 4. Evo React Import Paths
2+
3+
**Date:** 2026-03-12
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
The `@ebay/ebayui-core-react` package uses individual subpath exports for every component, requiring imports like:
12+
13+
```tsx
14+
import { EbayButton } from "@ebay/ebayui-core-react/ebay-button";
15+
import { EbayIconCart16 } from "@ebay/ebayui-core-react/icons/ebay-icon-cart-16";
16+
```
17+
18+
This approach optimizes tree-shaking at the cost of more verbose imports and increased cognitive overhead.
19+
20+
For `@evo-web/react`, we need to decide on an import strategy that balances bundle size optimization, developer experience, and build performance. The core issue with importing all 1,036+ icons from a single entry point is that bundlers must parse and tree-shake them on every build, significantly impacting build performance and risking bundle bloat if tree-shaking fails.
21+
22+
## Decision
23+
24+
Use a **hybrid import strategy** for `@evo-web/react`:
25+
26+
### Components (Unified Entry Point)
27+
28+
All non-icon components export from the main package entry:
29+
30+
```tsx
31+
import { EvoButton, EvoTextbox, EvoIconProvider } from "@evo-web/react";
32+
```
33+
34+
**Rationale:** Small number of components (~20-30 total expected), minimal tree-shaking overhead, simpler imports, better developer experience.
35+
36+
### Icons (Individual Subpath Exports)
37+
38+
Each icon has its own subpath export:
39+
40+
```tsx
41+
import { EvoIconCart16 } from "@evo-web/react/evo-icon-cart-16";
42+
import { EvoIconChevronDown24 } from "@evo-web/react/evo-icon-chevron-down-24";
43+
```
44+
45+
**Rationale:**
46+
47+
- **Bundle size**: Bundlers resolve exact files without parsing 1,000+ unused icons
48+
- **Build performance**: Skip tree-shaking analysis for unused icons entirely
49+
- **No bloat risk**: Direct imports prevent accidental bundling of unused icons
50+
- **Type safety**: TypeScript resolves icon types without loading all 1,036 definitions
51+
52+
## Consequences
53+
54+
### Positive
55+
56+
- Faster builds: Bundlers only process icons actually imported
57+
- Smaller bundles: Zero risk of accidentally including unused icons
58+
- Better DX for components: Single import for most use cases
59+
- Type performance: IDEs don't auto-complete 1,036 icon names from main export
60+
- Explicit icon usage: Clear dependency on which icons are used
61+
62+
### Negative
63+
64+
- Verbose icon imports: Each icon requires its own import statement
65+
- Mixed patterns: Developers must remember two import styles
66+
- Autocomplete fragmentation: Icons don't appear in main package autocomplete

package-lock.json

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
"vite-css-modules": "^1.12.0",
178178
"vite-plugin-cjs-interop": "^2.3.0",
179179
"vitest": "^4.0.18",
180+
"vitest-browser-react": "^2.0.5",
180181
"xmlserializer": "^0.6.1",
181182
"yargs": "^18.0.0"
182183
}

packages/evo-react/package.json

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
".": {
2323
"types": "./dist/index.d.ts",
2424
"default": "./dist/index.js"
25+
},
26+
"./evo-icon-*": {
27+
"types": "./dist/evo-icon/icons/evo-icon-*.d.ts",
28+
"default": "./dist/evo-icon/icons/evo-icon-*.js"
2529
}
2630
},
2731
"files": [
@@ -37,7 +41,7 @@
3741
"start": "storybook dev -p 9001",
3842
"test": "vitest run --browser.headless --passWithNoTests",
3943
"type:check": "tsc --noEmit",
40-
"update-icons": "exit 0",
44+
"update-icons": "tsx scripts/import-svg.ts",
4145
"version": "npm run update-icons && git add -A src"
4246
},
4347
"dependencies": {
@@ -54,11 +58,6 @@
5458
"makeup-typeahead": "^0.3.5",
5559
"react-remove-scroll": "^2.7.2"
5660
},
57-
"devDependencies": {
58-
"@vitejs/plugin-react": "^5.1.3",
59-
"@vitest/browser-playwright": "^4.0.18",
60-
"vitest-browser-react": "^2.0.5"
61-
},
6261
"peerDependencies": {
6362
"@ebay/skin": "^19",
6463
"react": "^19",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Loads icons from @ebay/skin and generates individual React icon components
3+
*/
4+
5+
import * as fs from "fs";
6+
import * as path from "path";
7+
import { createRequire } from "module";
8+
import { fileURLToPath } from "url";
9+
import { parseSync, stringify } from "svgson";
10+
import { deleteSync } from "del";
11+
12+
const require = createRequire(import.meta.url);
13+
14+
const skinDir = path.dirname(require.resolve("@ebay/skin/package.json"));
15+
const svgDir = path.join(skinDir, "dist/svg");
16+
17+
const __filename = fileURLToPath(import.meta.url);
18+
const __dirname = path.dirname(__filename);
19+
20+
const fileHeader = `// AUTO-GENERATED by \`importSVG\` script (\`scripts/import-svg.ts\`)`;
21+
22+
function parseSVG(skinIconsFile: string): any[] {
23+
const icons = fs.readFileSync(skinIconsFile).toString();
24+
return (
25+
((icons && parseSync(icons, { camelcase: false })) || {}).children || []
26+
);
27+
}
28+
29+
function parseSVGSymbols(skinIconsFile: string): any[] {
30+
const icons = parseSVG(skinIconsFile);
31+
return icons.filter(({ name }) => name === "symbol");
32+
}
33+
34+
function camelCased(str: string): string {
35+
return str
36+
.replace(/^icon-/, "")
37+
.replace(/-(\d+)/g, (_, num) => num)
38+
.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
39+
}
40+
41+
function saveIconComponents(svgFile: string): void {
42+
const svgSymbols = parseSVG(svgFile);
43+
const symbolsData = svgSymbols
44+
.filter(({ name }) => name === "symbol")
45+
.map((symbol) => ({
46+
id: symbol.attributes.id.replace(/^icon-/, ""),
47+
content: stringify(symbol),
48+
type: "icon",
49+
}));
50+
51+
// Clean up old icons
52+
const iconsDir = path.resolve(__dirname, "../src/evo-icon/icons");
53+
if (fs.existsSync(iconsDir)) {
54+
deleteSync([`${iconsDir}/*`]);
55+
} else {
56+
fs.mkdirSync(iconsDir, { recursive: true });
57+
}
58+
59+
// Create types file for icon components
60+
fs.writeFileSync(
61+
path.resolve(__dirname, "../src/evo-icon/icons/types.ts"),
62+
`${fileHeader}\n
63+
import type { ComponentProps } from 'react';
64+
import type { EvoIcon } from '../icon';
65+
66+
export type EvoIconComponentProps = Omit<ComponentProps<typeof EvoIcon>, 'name' | '__symbol'>;
67+
export type EvoIconComponent = (props: EvoIconComponentProps) => React.JSX.Element;
68+
`,
69+
);
70+
71+
const icons: Array<{ componentName: string; filePath: string }> = [];
72+
73+
symbolsData.forEach((data) => {
74+
const iconNameCamelCase = camelCased(data.id);
75+
const filename = path.resolve(
76+
__dirname,
77+
`../src/evo-icon/icons/evo-icon-${data.id}.tsx`,
78+
);
79+
const iconComponentName = `EvoIcon${iconNameCamelCase[0].toUpperCase()}${iconNameCamelCase.slice(1)}`;
80+
icons.push({
81+
componentName: iconComponentName,
82+
filePath: `evo-icon-${data.id}`,
83+
});
84+
85+
const content = `${fileHeader}\n
86+
import { EvoIcon } from "../icon";
87+
import type { EvoIconComponent } from "./types";
88+
89+
const SYMBOL = \`${data.content}\`;
90+
91+
export const ${iconComponentName}: EvoIconComponent = props => (
92+
<EvoIcon {...props} name="${iconNameCamelCase}" __symbol={SYMBOL} />
93+
);
94+
`;
95+
96+
fs.writeFileSync(filename, content);
97+
});
98+
99+
console.log(`Created ${icons.length} icon components.`);
100+
101+
// Create Storybook stories file
102+
const storiesFile = path.resolve(__dirname, "../src/evo-icon/icon.stories.tsx");
103+
104+
const storiesContent = `${fileHeader}\n
105+
import type { Meta } from "@storybook/react-vite";
106+
import { EvoIconProvider } from "./context";
107+
${icons.map(({ componentName, filePath }) => `import { ${componentName} } from "./icons/${filePath}";`).join("\n")}
108+
109+
const meta: Meta = {
110+
title: "Graphics & Icons/EvoIcon",
111+
tags: ["autodocs"],
112+
parameters: {
113+
docs: {
114+
description: {
115+
component: \`
116+
Icon components from the eBay Skin icon set. Each icon is available as an individual component for optimal tree-shaking.
117+
118+
## Usage
119+
120+
\\\`\\\`\\\`tsx
121+
import { EvoIconProvider } from "@evo-web/react";
122+
import { EvoIconCart16 } from "@evo-web/react/evo-icon-cart-16";
123+
124+
function App() {
125+
return (
126+
<EvoIconProvider>
127+
<EvoIconCart16 a11yText="Shopping cart" />
128+
</EvoIconProvider>
129+
);
130+
}
131+
\\\`\\\`\\\`
132+
133+
## Icons
134+
135+
Icons are imported individually via subpath exports:
136+
- \\\`@evo-web/react/evo-icon-<name>\\\` - Import specific icon component
137+
- Wrap your app with \\\`<EvoIconProvider>\\\` for better SSR performance
138+
- Use \\\`a11yText\\\` prop for accessible labels
139+
- Use \\\`a11yVariant="label"\\\` to use aria-label instead of title element
140+
141+
## Available Icons
142+
143+
Over 1,000 icons available in multiple sizes (12, 16, 20, 24, 32, 48, 64).
144+
\`,
145+
},
146+
},
147+
},
148+
};
149+
150+
export default meta;
151+
152+
export const AllIcons = () => (
153+
<EvoIconProvider>
154+
<table>
155+
<tbody>
156+
${icons
157+
.map(
158+
({ componentName, filePath }) => `
159+
<tr>
160+
<td>{${componentName}.name || "${filePath}"}</td>
161+
<td>
162+
<${componentName} />
163+
</td>
164+
</tr>
165+
`,
166+
)
167+
.join("\n")}
168+
</tbody>
169+
</table>
170+
</EvoIconProvider>
171+
);
172+
`;
173+
174+
fs.writeFileSync(storiesFile, storiesContent);
175+
console.log(`Created Storybook stories at ${storiesFile}`);
176+
}
177+
178+
// Main execution
179+
const skinIconsFile = path.join(svgDir, "icons.svg");
180+
const skinSVGSymbols = parseSVGSymbols(skinIconsFile);
181+
console.log(`Found ${skinSVGSymbols.length} icons in Skin.`);
182+
183+
// Generate individual icon components
184+
saveIconComponents(skinIconsFile);
185+
186+
console.log("✅ Icon generation complete!");

packages/evo-react/src/evo-button/button.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
Size,
99
Split,
1010
} from "./types";
11+
import { EvoIconChevronDown16 } from "../evo-icon/icons/evo-icon-chevron-down-16";
1112
import "@ebay/skin/button.mjs";
1213

1314
export function EvoButton(props: AnchorButtonProps): React.JSX.Element;
@@ -83,8 +84,7 @@ export function EvoButton(
8384
return (
8485
<span className="btn__cell">
8586
<span className="btn__text">{children}</span>
86-
{/* TODO: Replace with <EvoIconChevronDown16 /> when available */}
87-
<span></span>
87+
<EvoIconChevronDown16 />
8888
</span>
8989
);
9090
default:

packages/evo-react/src/evo-button/test/__snapshots__/test.server.tsx.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
exports[`EvoButton SSR > should render button with bodyState=expand 1`] = `"<button class="btn btn--secondary"><span class="btn__cell"><span class="btn__text">Button</span><span>▼</span></span></button>"`;
3+
exports[`EvoButton SSR > should render button with bodyState=expand 1`] = `"<button class="btn btn--secondary"><span class="btn__cell"><span class="btn__text">Button</span><svg class="icon icon--16" xmlns="http://www.w3.org/2000/svg" focusable="false" aria-hidden="true"><use xlink:href="#icon-chevron-down-16"></use><defs><symbol viewBox="0 0 16 16" id="icon-chevron-down-16"><path d="M8.707 12.707a1 1 0 0 1-1.414 0l-6-6a1 1 0 0 1 1.414-1.414L8 10.586l5.293-5.293a1 1 0 1 1 1.414 1.414l-6 6Z"/></symbol></defs></svg></span></button>"`;
44

55
exports[`EvoButton SSR > should render button with bodyState=loading 1`] = `"<button aria-live="polite" class="btn btn--secondary"><span class="btn__cell"><span>Loading...</span></span></button>"`;
66

@@ -64,7 +64,7 @@ exports[`EvoButton SSR > should render disabled link without href 1`] = `"<a cla
6464

6565
exports[`EvoButton SSR > should render fake version (anchor) 1`] = `"<a aria-label="fake button" class="fake-btn fake-btn--primary fake-btn--large" href="https://ebay.com">Link Button</a>"`;
6666

67-
exports[`EvoButton SSR > should render form variant with expand 1`] = `"<button class="btn btn--form"><span class="btn__cell"><span class="btn__text">Form Expand</span><span>▼</span></span></button>"`;
67+
exports[`EvoButton SSR > should render form variant with expand 1`] = `"<button class="btn btn--form"><span class="btn__cell"><span class="btn__text">Form Expand</span><svg class="icon icon--16" xmlns="http://www.w3.org/2000/svg" focusable="false" aria-hidden="true"><use xlink:href="#icon-chevron-down-16"></use></svg></span></button>"`;
6868
6969
exports[`EvoButton SSR > should render large fixed-height button 1`] = `"<button class="btn btn--secondary btn--large btn--large-fixed-height">Large Fixed Height</button>"`;
7070
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { createContext, useRef, type ReactNode } from "react";
2+
3+
export const IconContext = createContext<Set<string> | null>(null);
4+
5+
export const ROOT_ID = "evo-web-svg-symbols";
6+
7+
export function EvoIconProvider({ children }: { children: ReactNode }) {
8+
const lookupRef = useRef<Set<string>>(new Set());
9+
10+
return (
11+
<IconContext.Provider value={lookupRef.current}>
12+
<svg
13+
id={ROOT_ID}
14+
style={{ position: "absolute", height: "0px", width: "0px" }}
15+
focusable={false}
16+
aria-hidden="true"
17+
/>
18+
{children}
19+
</IconContext.Provider>
20+
);
21+
}

0 commit comments

Comments
 (0)