Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 5 additions & 0 deletions .changeset/new-feet-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evo-web/react": patch
---

feat(evo-react): add icon components
66 changes: 66 additions & 0 deletions docs/adr/0004-evo-react-import-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 4. Evo React Import Paths

**Date:** 2026-03-12

## Status

Accepted

## Context

The `@ebay/ebayui-core-react` package uses individual subpath exports for every component, requiring imports like:

```tsx
import { EbayButton } from "@ebay/ebayui-core-react/ebay-button";
import { EbayIconCart16 } from "@ebay/ebayui-core-react/icons/ebay-icon-cart-16";
```

This approach optimizes tree-shaking at the cost of more verbose imports and increased cognitive overhead.

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.

## Decision

Use a **hybrid import strategy** for `@evo-web/react`:

### Components (Unified Entry Point)

All non-icon components export from the main package entry:

```tsx
import { EvoButton, EvoTextbox, EvoIconProvider } from "@evo-web/react";
```

**Rationale:** Small number of components (~20-30 total expected), minimal tree-shaking overhead, simpler imports, better developer experience.

### Icons (Individual Subpath Exports)

Each icon has its own subpath export:

```tsx
import { EvoIconCart16 } from "@evo-web/react/evo-icon-cart-16";
import { EvoIconChevronDown24 } from "@evo-web/react/evo-icon-chevron-down-24";
```

**Rationale:**

- **Bundle size**: Bundlers resolve exact files without parsing 1,000+ unused icons
- **Build performance**: Skip tree-shaking analysis for unused icons entirely
- **No bloat risk**: Direct imports prevent accidental bundling of unused icons
- **Type safety**: TypeScript resolves icon types without loading all 1,036 definitions

## Consequences

### Positive

- Faster builds: Bundlers only process icons actually imported
- Smaller bundles: Zero risk of accidentally including unused icons
- Better DX for components: Single import for most use cases
- Type performance: IDEs don't auto-complete 1,036 icon names from main export
- Explicit icon usage: Clear dependency on which icons are used

### Negative

- Verbose icon imports: Each icon requires its own import statement
- Mixed patterns: Developers must remember two import styles
- Autocomplete fragmentation: Icons don't appear in main package autocomplete
6 changes: 1 addition & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
"vite-css-modules": "^1.12.0",
"vite-plugin-cjs-interop": "^2.3.0",
"vitest": "^4.0.18",
"vitest-browser-react": "^2.0.5",
"xmlserializer": "^0.6.1",
"yargs": "^18.0.0"
}
Expand Down
11 changes: 5 additions & 6 deletions packages/evo-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./evo-icon-*": {
"types": "./dist/evo-icon/icons/evo-icon-*.d.ts",
"default": "./dist/evo-icon/icons/evo-icon-*.js"
}
},
"files": [
Expand All @@ -37,7 +41,7 @@
"start": "storybook dev -p 9001",
"test": "vitest run --browser.headless --passWithNoTests",
"type:check": "tsc --noEmit",
"update-icons": "exit 0",
"update-icons": "tsx scripts/import-svg.ts",
"version": "npm run update-icons && git add -A src"
},
"dependencies": {
Expand All @@ -54,11 +58,6 @@
"makeup-typeahead": "^0.3.5",
"react-remove-scroll": "^2.7.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.1.3",
"@vitest/browser-playwright": "^4.0.18",
"vitest-browser-react": "^2.0.5"
},
"peerDependencies": {
"@ebay/skin": "^19",
"react": "^19",
Expand Down
186 changes: 186 additions & 0 deletions packages/evo-react/scripts/import-svg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Loads icons from @ebay/skin and generates individual React icon components
*/

import * as fs from "fs";
import * as path from "path";
import { createRequire } from "module";
import { fileURLToPath } from "url";
import { parseSync, stringify } from "svgson";
import { deleteSync } from "del";

const require = createRequire(import.meta.url);

const skinDir = path.dirname(require.resolve("@ebay/skin/package.json"));
const svgDir = path.join(skinDir, "dist/svg");

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const fileHeader = `// AUTO-GENERATED by \`importSVG\` script (\`scripts/import-svg.ts\`)`;

function parseSVG(skinIconsFile: string): any[] {
const icons = fs.readFileSync(skinIconsFile).toString();
return (
((icons && parseSync(icons, { camelcase: false })) || {}).children || []
);
}

function parseSVGSymbols(skinIconsFile: string): any[] {
const icons = parseSVG(skinIconsFile);
return icons.filter(({ name }) => name === "symbol");
}

function camelCased(str: string): string {
return str
.replace(/^icon-/, "")
.replace(/-(\d+)/g, (_, num) => num)
.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
}

function saveIconComponents(svgFile: string): void {
const svgSymbols = parseSVG(svgFile);
const symbolsData = svgSymbols
.filter(({ name }) => name === "symbol")
.map((symbol) => ({
id: symbol.attributes.id.replace(/^icon-/, ""),
content: stringify(symbol),
type: "icon",
}));

// Clean up old icons
const iconsDir = path.resolve(__dirname, "../src/evo-icon/icons");
if (fs.existsSync(iconsDir)) {
deleteSync([`${iconsDir}/*`]);
} else {
fs.mkdirSync(iconsDir, { recursive: true });
}

// Create types file for icon components
fs.writeFileSync(
path.resolve(__dirname, "../src/evo-icon/icons/types.ts"),
`${fileHeader}\n
import type { ComponentProps } from 'react';
import type { EvoIcon } from '../icon';

export type EvoIconComponentProps = Omit<ComponentProps<typeof EvoIcon>, 'name' | '__symbol'>;
export type EvoIconComponent = (props: EvoIconComponentProps) => React.JSX.Element;
`,
);

const icons: Array<{ componentName: string; filePath: string }> = [];

symbolsData.forEach((data) => {
const iconNameCamelCase = camelCased(data.id);
const filename = path.resolve(
__dirname,
`../src/evo-icon/icons/evo-icon-${data.id}.tsx`,
);
const iconComponentName = `EvoIcon${iconNameCamelCase[0].toUpperCase()}${iconNameCamelCase.slice(1)}`;
icons.push({
componentName: iconComponentName,
filePath: `evo-icon-${data.id}`,
});

const content = `${fileHeader}\n
import { EvoIcon } from "../icon";
import type { EvoIconComponent } from "./types";

const SYMBOL = \`${data.content}\`;

export const ${iconComponentName}: EvoIconComponent = props => (
<EvoIcon {...props} name="${iconNameCamelCase}" __symbol={SYMBOL} />
);
`;

fs.writeFileSync(filename, content);
});

console.log(`Created ${icons.length} icon components.`);

// Create Storybook stories file
const storiesFile = path.resolve(__dirname, "../src/evo-icon/icon.stories.tsx");

const storiesContent = `${fileHeader}\n
import type { Meta } from "@storybook/react-vite";
import { EvoIconProvider } from "./context";
${icons.map(({ componentName, filePath }) => `import { ${componentName} } from "./icons/${filePath}";`).join("\n")}

const meta: Meta = {
title: "Graphics & Icons/EvoIcon",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: \`
Icon components from the eBay Skin icon set. Each icon is available as an individual component for optimal tree-shaking.

## Usage

\\\`\\\`\\\`tsx
import { EvoIconProvider } from "@evo-web/react";
import { EvoIconCart16 } from "@evo-web/react/evo-icon-cart-16";

function App() {
return (
<EvoIconProvider>
<EvoIconCart16 a11yText="Shopping cart" />
</EvoIconProvider>
);
}
\\\`\\\`\\\`

## Icons

Icons are imported individually via subpath exports:
- \\\`@evo-web/react/evo-icon-<name>\\\` - Import specific icon component
- Wrap your app with \\\`<EvoIconProvider>\\\` for better SSR performance
- Use \\\`a11yText\\\` prop for accessible labels
- Use \\\`a11yVariant="label"\\\` to use aria-label instead of title element

## Available Icons

Over 1,000 icons available in multiple sizes (12, 16, 20, 24, 32, 48, 64).
\`,
},
},
},
};

export default meta;

export const AllIcons = () => (
<EvoIconProvider>
<table>
<tbody>
${icons
.map(
({ componentName, filePath }) => `
<tr>
<td>{${componentName}.name || "${filePath}"}</td>
<td>
<${componentName} />
</td>
</tr>
`,
)
.join("\n")}
</tbody>
</table>
</EvoIconProvider>
);
`;

fs.writeFileSync(storiesFile, storiesContent);
console.log(`Created Storybook stories at ${storiesFile}`);
}

// Main execution
const skinIconsFile = path.join(svgDir, "icons.svg");
const skinSVGSymbols = parseSVGSymbols(skinIconsFile);
console.log(`Found ${skinSVGSymbols.length} icons in Skin.`);

// Generate individual icon components
saveIconComponents(skinIconsFile);

console.log("✅ Icon generation complete!");
4 changes: 2 additions & 2 deletions packages/evo-react/src/evo-button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
Size,
Split,
} from "./types";
import { EvoIconChevronDown16 } from "../evo-icon/icons/evo-icon-chevron-down-16";
import "@ebay/skin/button.mjs";

export function EvoButton(props: AnchorButtonProps): React.JSX.Element;
Expand Down Expand Up @@ -83,8 +84,7 @@ export function EvoButton(
return (
<span className="btn__cell">
<span className="btn__text">{children}</span>
{/* TODO: Replace with <EvoIconChevronDown16 /> when available */}
<span>▼</span>
<EvoIconChevronDown16 />
</span>
);
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

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>"`;
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>"`;

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>"`;

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

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>"`;

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>"`;
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>"`;

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>"`;

Expand Down
21 changes: 21 additions & 0 deletions packages/evo-react/src/evo-icon/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext, useRef, type ReactNode } from "react";

export const IconContext = createContext<Set<string> | null>(null);

export const ROOT_ID = "evo-web-svg-symbols";

export function EvoIconProvider({ children }: { children: ReactNode }) {
const lookupRef = useRef<Set<string>>(new Set());

return (
<IconContext.Provider value={lookupRef.current}>
<svg
id={ROOT_ID}
style={{ position: "absolute", height: "0px", width: "0px" }}
focusable={false}
aria-hidden="true"
/>
{children}
</IconContext.Provider>
);
}
Loading
Loading