Skip to content

Commit 2378f8d

Browse files
committed
feat: extract @tiny-design/icons package with tree-shakeable SVG icons
- Add new @tiny-design/icons package with 242 individual SVG icon components generated from the existing iconfont.svg font file - Each icon is a forwardRef component with size, color, className, style props - Package uses sideEffects: false and unbundle mode for optimal tree-shaking - Remove font-based Icon component from @tiny-design/react - Migrate all internal Icon usages (rate, layout/sidebar, docs app) to SVG icons - Add @tiny-design/icons to docs app live code playground scope - Add snapshot and prop tests for icon components
1 parent 17dd9ab commit 2378f8d

284 files changed

Lines changed: 7318 additions & 516 deletions

File tree

Some content is hidden

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

apps/docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"preview": "vite preview"
99
},
1010
"dependencies": {
11+
"@tiny-design/icons": "workspace:*",
1112
"@tiny-design/react": "workspace:*",
1213
"@tiny-design/tokens": "workspace:*",
1314
"@mdx-js/react": "^3.1.1",

apps/docs/src/components/code-block/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Prism.languages.bash = Prism.languages.shell = {
2424
import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live';
2525
import { LightCodeTheme, DarkCodeTheme } from './code-theme';
2626
import * as Components from '@tiny-design/react';
27+
import * as SvgIcons from '@tiny-design/icons';
2728
import CollapseTransition from '@tiny-design/react/collapse-transition';
2829
import { useTheme } from '@tiny-design/react';
2930
import { useLocaleContext } from '../../context/locale-context';
@@ -49,7 +50,7 @@ export const CodeBlock = ({ children, className, live }: Props): React.ReactElem
4950
if (live) {
5051
return (
5152
<div className="code-block__container" ref={ref}>
52-
<LiveProvider code={children.trim()} theme={codeTheme} scope={Components}>
53+
<LiveProvider code={children.trim()} theme={codeTheme} scope={{ ...Components, ...SvgIcons }}>
5354
<LivePreview className="code-block__previewer" />
5455
<LiveError />
5556
<CollapseTransition isShow={showCode}>

apps/docs/src/components/doc-footer/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react';
22
import './component-page.scss';
33
import { Link, useLocation } from 'react-router-dom';
44
import { RouterItem } from '../../routers';
5-
import { Icon } from '@tiny-design/react';
5+
import { IconLeft, IconRight } from '@tiny-design/icons';
66

77
type Props = {
88
routers: RouterItem[];
@@ -31,7 +31,7 @@ export const DocFooter = ({ routers }: Props): React.ReactElement => {
3131
<footer className="component-page__footer">
3232
{siblingMenus[0] && siblingMenus[0].route !== currRouteName ? (
3333
<Link to={`${baseUrl}/${siblingMenus[0].route!}`}>
34-
<Icon name="left" className="component-page__footer-icon-left" />
34+
<IconLeft className="component-page__footer-icon-left" />
3535
<span className="component-page__footer-label">{siblingMenus[0].title}</span>
3636
</Link>
3737
) : (
@@ -40,7 +40,7 @@ export const DocFooter = ({ routers }: Props): React.ReactElement => {
4040
{siblingMenus[1] && siblingMenus[1].route !== currRouteName && (
4141
<Link to={`${baseUrl}/${siblingMenus[1].route!}`}>
4242
<span className="component-page__footer-label">{siblingMenus[1].title}</span>
43-
<Icon name="right" className="component-page__footer-icon-right" />
43+
<IconRight className="component-page__footer-icon-right" />
4444
</Link>
4545
)}
4646
</footer>

apps/docs/src/components/feature-block/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import React from 'react';
22
import './feature-block.scss';
3-
import { Icon } from '@tiny-design/react';
3+
import type { IconProps } from '@tiny-design/icons';
44

55
type Props = {
6-
icon: string;
6+
icon: React.FC<IconProps>;
77
title: string;
88
desc: string;
99
style?: React.CSSProperties;
1010
};
1111

12-
export const FeatureBlock = ({ icon, title, desc, style }: Props): React.ReactElement => (
12+
export const FeatureBlock = ({ icon: IconComponent, title, desc, style }: Props): React.ReactElement => (
1313
<div className="feature-block" style={style}>
1414
<div className="feature-block__icon-container">
15-
<Icon name={icon} className="feature-block__icon" size={24} />
15+
<IconComponent className="feature-block__icon" size={24} />
1616
</div>
1717
<h3 className="feature-block__title">{title}</h3>
1818
<p className="feature-block__desc">{desc}</p>

apps/docs/src/components/header/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import React from 'react';
22
import './header.scss';
33
import { NavLink } from 'react-router-dom';
44
import pkg from '../../../../../packages/react/package.json';
5-
import { Icon, Link } from '@tiny-design/react';
5+
import { Link } from '@tiny-design/react';
6+
import { IconGithub } from '@tiny-design/icons';
67
import { useSidebarToggle } from '../../context/sidebar-toggle-context';
78
import { useLocaleContext } from '../../context/locale-context';
89
import { ThemeToggle } from './theme-toggle';
@@ -64,7 +65,7 @@ export const Header = (): React.ReactElement => {
6465
</li>
6566
<li className="header__nav-item">
6667
<Link href={repository.url} underline={false} rel="noreferrer noopener">
67-
<Icon name="github" color="currentColor" size={19} />
68+
<IconGithub color="currentColor" size={19} />
6869
</Link>
6970
</li>
7071
<li className="header__nav-item">

apps/docs/src/containers/home/footer/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import './footer.scss';
33
import pkg from '../../../../../../packages/react/package.json';
44

55
const { version, repository } = pkg;
6-
import { Icon } from '@tiny-design/react';
6+
import { IconGithub } from '@tiny-design/icons';
77
import logoSvg from '../../../assets/logo/logo.svg';
88

99
export const Footer = (): React.ReactElement => (
@@ -15,7 +15,7 @@ export const Footer = (): React.ReactElement => (
1515
</div>
1616
<a href={repository.url} target="_blank" className="footer__link" rel="noreferrer noopener">
1717
<div className="footer__github">
18-
<Icon name="github" size={25} color="#fff" className="footer__icon" />
18+
<IconGithub size={25} color="#fff" className="footer__icon" />
1919
<span>GitHub</span>
2020
</div>
2121
</a>

apps/docs/src/containers/home/index.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import React, { useMemo } from 'react';
22
import './home.scss';
33
import { useNavigate } from 'react-router-dom';
4-
import { Button, Icon, Flex, Typography, Statistic, Card, Row, Col } from '@tiny-design/react';
4+
import { Button, Flex, Typography, Statistic, Card, Row, Col } from '@tiny-design/react';
5+
import {
6+
IconColorlens, IconOrgUnit, IconPuzzle, IconAccessible,
7+
IconStar, IconStructure, IconProcess, IconEye, IconEditFile, IconFeedback,
8+
IconGithub,
9+
} from '@tiny-design/icons';
10+
import type { IconProps } from '@tiny-design/icons';
511
import { Footer } from './footer';
612
import { useLocaleContext } from '../../context/locale-context';
713
import { getComponentMenu } from '../../routers';
@@ -15,20 +21,20 @@ const HomePage = (): React.ReactElement => {
1521
const navigate = useNavigate();
1622
const { siteLocale: s } = useLocaleContext();
1723

18-
const features = [
19-
{ icon: 'colorlens', title: s.home.features.themeable, desc: s.home.features.themeableDesc },
20-
{ icon: 'org-unit', title: s.home.features.elegant, desc: s.home.features.elegantDesc },
21-
{ icon: 'puzzle', title: s.home.features.composable, desc: s.home.features.composableDesc },
22-
{ icon: 'accessible', title: s.home.features.accessible, desc: s.home.features.accessibleDesc },
24+
const features: { Icon: React.FC<IconProps>; title: string; desc: string }[] = [
25+
{ Icon: IconColorlens, title: s.home.features.themeable, desc: s.home.features.themeableDesc },
26+
{ Icon: IconOrgUnit, title: s.home.features.elegant, desc: s.home.features.elegantDesc },
27+
{ Icon: IconPuzzle, title: s.home.features.composable, desc: s.home.features.composableDesc },
28+
{ Icon: IconAccessible, title: s.home.features.accessible, desc: s.home.features.accessibleDesc },
2329
];
2430

25-
const categoryIcons = ['star', 'structure', 'process', 'eye', 'edit-file', 'feedback', 'puzzle'];
31+
const categoryIcons: React.FC<IconProps>[] = [IconStar, IconStructure, IconProcess, IconEye, IconEditFile, IconFeedback, IconPuzzle];
2632

2733
const { stats, categories } = useMemo(() => {
2834
const menu = getComponentMenu(s);
2935
const totalComponents = menu.reduce((sum, cat) => sum + (cat.children?.length ?? 0), 0);
3036
const cats = menu.map((cat, i) => ({
31-
icon: categoryIcons[i] ?? 'puzzle',
37+
Icon: categoryIcons[i] ?? IconPuzzle,
3238
name: cat.title,
3339
count: cat.children?.length ?? 0,
3440
route: cat.children?.[0]?.route ?? '',
@@ -68,7 +74,7 @@ const HomePage = (): React.ReactElement => {
6874
<Button
6975
className="home__btn"
7076
size="lg"
71-
icon={<Icon name="github" color="currentColor" />}
77+
icon={<IconGithub color="currentColor" />}
7278
onClick={() => window.open(repository.url)}>
7379
{s.home.github}
7480
</Button>
@@ -83,7 +89,7 @@ const HomePage = (): React.ReactElement => {
8389
<Card bordered={false} className="home__feature-card" style={{ animationDelay: `${i * 0.1}s` }}>
8490
<Flex vertical align="center" gap="md">
8591
<div className="home__feature-icon">
86-
<Icon name={feature.icon} size={24} />
92+
<feature.Icon size={24} />
8793
</div>
8894
<Typography.Heading level={3}>{feature.title}</Typography.Heading>
8995
<Typography.Paragraph>{feature.desc}</Typography.Paragraph>
@@ -111,7 +117,7 @@ const HomePage = (): React.ReactElement => {
111117
onClick={() => navigate(`/components/${cat.route}`)}
112118
style={{ animationDelay: `${i * 0.08}s` }}>
113119
<Flex vertical align="center" gap="sm">
114-
<Icon name={cat.icon} size={32} color="currentColor" />
120+
<cat.Icon size={32} color="currentColor" />
115121
<Typography.Text strong>{cat.name}</Typography.Text>
116122
<Typography.Text className="home__category-count">{s.home.nComponents(cat.count)}</Typography.Text>
117123
</Flex>

packages/icons/jest.config.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'jsdom',
4+
roots: ['<rootDir>/src/'],
5+
verbose: true,
6+
setupFilesAfterEnv: ['<rootDir>/test-setup.js'],
7+
transform: {
8+
'^.+\\.tsx?$': ['ts-jest', {
9+
tsconfig: 'tsconfig.test.json',
10+
isolatedModules: true,
11+
}],
12+
},
13+
};

packages/icons/package.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"name": "@tiny-design/icons",
3+
"version": "1.0.4",
4+
"description": "SVG icon components for tiny-design",
5+
"license": "MIT",
6+
"keywords": [
7+
"tiny",
8+
"tiny-design",
9+
"icons",
10+
"svg",
11+
"react"
12+
],
13+
"repository": {
14+
"type": "git",
15+
"url": "https://github.com/wangdicoder/tiny-design.git",
16+
"directory": "packages/icons"
17+
},
18+
"author": "Di Wang<wangdicoder@gmail.com>",
19+
"main": "lib/index.js",
20+
"module": "es/index.js",
21+
"typings": "lib/index.d.ts",
22+
"exports": {
23+
".": {
24+
"types": "./lib/index.d.ts",
25+
"import": "./es/index.js",
26+
"require": "./lib/index.js"
27+
},
28+
"./lib/*": "./lib/*",
29+
"./es/*": "./es/*"
30+
},
31+
"sideEffects": false,
32+
"files": [
33+
"lib",
34+
"es"
35+
],
36+
"scripts": {
37+
"generate": "node scripts/generate-icons.js",
38+
"build": "npm run clean && npm run generate && tsdown",
39+
"clean": "rimraf lib es",
40+
"test": "jest",
41+
"test:watch": "jest --watch",
42+
"test:coverage": "jest --coverage",
43+
"test:update": "jest --updateSnapshot"
44+
},
45+
"peerDependencies": {
46+
"react": ">=18.0.0"
47+
},
48+
"devDependencies": {
49+
"@testing-library/jest-dom": "^6.0.0",
50+
"@testing-library/react": "^14.0.0",
51+
"@types/jest": "^29.0.0",
52+
"@types/react": "^18.2.0",
53+
"jest": "^29.0.0",
54+
"jest-environment-jsdom": "^29.0.0",
55+
"react": "^18.2.0",
56+
"react-dom": "^18.2.0",
57+
"rimraf": "^3.0.2",
58+
"ts-jest": "^29.0.0",
59+
"tsdown": "^0.21.1",
60+
"typescript": "^5.4.0"
61+
}
62+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const SVG_FONT_PATH = path.resolve(__dirname, '../../tokens/scss/fonts/iconfont.svg');
5+
const SRC_DIR = path.resolve(__dirname, '../src');
6+
7+
function parseName(glyphName) {
8+
// Strip leading dash, convert to PascalCase
9+
const clean = glyphName.replace(/^-/, '');
10+
return clean
11+
.split(/[-_]+/)
12+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
13+
.join('');
14+
}
15+
16+
function toFileName(glyphName) {
17+
// Strip leading dash, keep kebab-case
18+
return glyphName.replace(/^-/, '');
19+
}
20+
21+
function extractGlyphs(svgContent) {
22+
const glyphs = [];
23+
const regex = /<glyph\s+glyph-name="([^"]+)"[^>]*\sd="([^"]+)"/g;
24+
let match;
25+
while ((match = regex.exec(svgContent)) !== null) {
26+
const [, name, d] = match;
27+
if (name && d) {
28+
glyphs.push({ name, d });
29+
}
30+
}
31+
return glyphs;
32+
}
33+
34+
function generateComponent(pascalName, pathData) {
35+
return `import { forwardRef } from 'react';
36+
import type { IconProps } from './types';
37+
38+
const ${pascalName} = forwardRef<SVGSVGElement, IconProps>((props, ref) => {
39+
const { size = '1em', color = 'currentColor', className, style, ...rest } = props;
40+
return (
41+
<svg
42+
ref={ref}
43+
viewBox="0 0 1024 1024"
44+
width={size}
45+
height={size}
46+
fill={color}
47+
className={className}
48+
style={style}
49+
{...rest}
50+
>
51+
<g transform="translate(0, 896) scale(1, -1)">
52+
<path d="${pathData}" />
53+
</g>
54+
</svg>
55+
);
56+
});
57+
58+
${pascalName}.displayName = '${pascalName}';
59+
60+
export { ${pascalName} };
61+
`;
62+
}
63+
64+
function generateTypes() {
65+
return `import type { SVGAttributes } from 'react';
66+
67+
export interface IconProps extends SVGAttributes<SVGSVGElement> {
68+
size?: string | number;
69+
color?: string;
70+
}
71+
`;
72+
}
73+
74+
function main() {
75+
const svgContent = fs.readFileSync(SVG_FONT_PATH, 'utf-8');
76+
const glyphs = extractGlyphs(svgContent);
77+
78+
console.log(`Found ${glyphs.length} glyphs`);
79+
80+
// Ensure src dir exists
81+
fs.mkdirSync(SRC_DIR, { recursive: true });
82+
83+
// Write types.ts
84+
fs.writeFileSync(path.join(SRC_DIR, 'types.ts'), generateTypes());
85+
86+
const exports = [];
87+
88+
for (const glyph of glyphs) {
89+
const pascalName = 'Icon' + parseName(glyph.name);
90+
const fileName = 'icon-' + toFileName(glyph.name);
91+
const filePath = path.join(SRC_DIR, `${fileName}.tsx`);
92+
93+
fs.writeFileSync(filePath, generateComponent(pascalName, glyph.d));
94+
exports.push({ pascalName, fileName });
95+
}
96+
97+
// Write index.ts barrel
98+
const indexLines = [
99+
`export type { IconProps } from './types';`,
100+
'',
101+
...exports.map((e) => `export { ${e.pascalName} } from './${e.fileName}';`),
102+
'',
103+
];
104+
fs.writeFileSync(path.join(SRC_DIR, 'index.ts'), indexLines.join('\n'));
105+
106+
console.log(`Generated ${exports.length} icon components`);
107+
}
108+
109+
main();

0 commit comments

Comments
 (0)