feat(lucide-react): Add DynamicIcon component (#2686)

* Adding the DynamicIcon component

* Fix imports

* Add docs

* Formatting

* Fix use client in output rollup

* revert changes

* Fix formatting

* Revert changes in icons directory

* Revert time command

* update exports
This commit is contained in:
Eric Fennis
2025-01-10 14:35:28 +01:00
committed by GitHub
parent d5fe5a0ef4
commit 58c2e108c3
11 changed files with 281 additions and 110 deletions

View File

@@ -23,18 +23,53 @@
],
"author": "Eric Fennis",
"amdName": "lucide-react",
"source": "src/lucide-react.ts",
"main": "dist/cjs/lucide-react.js",
"main:umd": "dist/umd/lucide-react.js",
"module": "dist/esm/lucide-react.js",
"unpkg": "dist/umd/lucide-react.min.js",
"typings": "dist/lucide-react.d.ts",
"sideEffects": false,
"types": "dist/lucide-react.d.ts",
"files": [
"dist",
"dynamicIconImports.js",
"dynamicIconImports.js.map",
"dynamicIconImports.d.ts"
"dist"
],
"exports": {
".": {
"types": "./dist/lucide-react.d.ts",
"import": "./dist/esm/lucide-react.js",
"browser": "./dist/esm/lucide-react.js",
"require": "./dist/cjs/lucide-react.js",
"node": "./dist/cjs/lucide-react.js"
},
"./icons": {
"types": "./dist/lucide-react.d.ts",
"import": "./dist/esm/lucide-react.js",
"browser": "./dist/esm/lucide-react.js",
"require": "./dist/cjs/lucide-react.js",
"node": "./dist/cjs/lucide-react.js"
},
"./icons/*": {
"types": "./dist/icons/*.d.ts",
"import": "./dist/esm/icons/*.js",
"browser": "./dist/esm/icons/*.js",
"require": "./dist/cjs/icons/*.js",
"node": "./dist/cjs/icons/*.js"
},
"./dynamic": {
"types": "./dist/dynamic.d.ts",
"import": "./dist/esm/dynamic.js",
"browser": "./dist/esm/dynamic.js",
"require": "./dist/cjs/dynamic.js",
"node": "./dist/cjs/dynamic.js"
},
"./dynamicIconImports": {
"types": "./dist/dynamicIconImports.d.ts",
"import": "./dist/esm/dynamicIconImports.js",
"browser": "./dist/esm/dynamicIconImports.js",
"require": "./dist/cjs/dynamicIconImports.js",
"node": "./dist/cjs/dynamicIconImports.js"
},
"./src/*": "./src/*.ts",
"./package.json": "./package.json"
},
"sideEffects": false,
"scripts": {
"build": "pnpm clean && pnpm copy:license && pnpm build:icons && pnpm typecheck && pnpm build:bundles",
"copy:license": "cp ../../LICENSE ./LICENSE",
@@ -60,6 +95,7 @@
"react-dom": "18.2.0",
"rollup": "^4.22.4",
"rollup-plugin-dts": "^6.1.0",
"rollup-plugin-preserve-directives": "^0.4.0",
"typescript": "^4.9.5",
"vite": "5.1.8",
"vitest": "^1.1.1"

View File

@@ -1,4 +1,5 @@
import plugins from '@lucide/rollup-plugins';
import preserveDirectives from 'rollup-plugin-preserve-directives';
import pkg from './package.json' assert { type: 'json' };
import dts from 'rollup-plugin-dts';
import getAliasesEntryNames from './scripts/getAliasesEntryNames.mjs';
@@ -34,14 +35,15 @@ const bundles = [
},
{
format: 'esm',
inputs: ['src/dynamicIconImports.ts'],
outputFile: 'dynamicIconImports.js',
inputs: ['src/dynamic.ts', 'src/dynamicIconImports.ts', 'src/DynamicIcon.ts'],
outputDir,
preserveModules: true,
external: [/src/],
paths: (id) => {
if (id.match(/src/)) {
const [, modulePath] = id.match(/src\/(.*)\.ts/);
return `dist/esm/${modulePath}.js`;
return `${modulePath}.js`;
}
},
},
@@ -62,7 +64,14 @@ const configs = bundles
}) =>
inputs.map((input) => ({
input,
plugins: plugins({ pkg, minify }),
plugins: [
...plugins({ pkg, minify }),
// Make sure we emit "use client" directive to make it compatible with Next.js
preserveDirectives({
include: 'src/DynamicIcon.ts',
suppressPreserveModulesWarning: true,
}),
],
external: ['react', 'prop-types', ...external],
output: {
name: packageName,
@@ -95,7 +104,31 @@ export default [
input: 'src/dynamicIconImports.ts',
output: [
{
file: `dynamicIconImports.d.ts`,
file: `dist/dynamicIconImports.d.ts`,
format: 'es',
},
],
plugins: [
dts({
exclude: ['./src/icons'],
}),
],
},
{
input: 'src/dynamic.ts',
output: [
{
file: `dist/dynamic.d.ts`,
format: 'es',
},
],
plugins: [dts()],
},
{
input: 'src/DynamicIcon.ts',
output: [
{
file: `dist/DynamicIcon.d.ts`,
format: 'es',
},
],

View File

@@ -7,6 +7,9 @@ export default ({ componentName, iconName, children, getSvg, deprecated, depreca
return `
import createLucideIcon from '../createLucideIcon';
import { IconNode } from '../types';
export const __iconNode: IconNode = ${JSON.stringify(children)}
/**
* @component @name ${componentName}
@@ -19,7 +22,7 @@ import createLucideIcon from '../createLucideIcon';
* @returns {JSX.Element} JSX Element
* ${deprecated ? `@deprecated ${deprecationReason}` : ''}
*/
const ${componentName} = createLucideIcon('${componentName}', ${JSON.stringify(children)});
const ${componentName} = createLucideIcon('${componentName}', __iconNode);
export default ${componentName};
`;

View File

@@ -0,0 +1,73 @@
'use client';
import { createElement, forwardRef, useEffect, useState } from 'react';
import { IconNode, LucideIcon, LucideProps } from './types';
import dynamicIconImports from './dynamicIconImports';
import Icon from './Icon';
export type DynamicIconModule = { default: LucideIcon; __iconNode: IconNode };
export type IconName = keyof typeof dynamicIconImports;
export const iconNames = Object.keys(dynamicIconImports) as Array<IconName>;
interface DynamicIconComponentProps extends LucideProps {
name: IconName;
fallback?: () => JSX.Element | null;
}
async function getIconNode(name: IconName) {
if (!(name in dynamicIconImports)) {
throw new Error('[lucide-react]: Name in Lucide DynamicIcon not found');
}
// TODO: Replace this with a generic iconNode package.
const icon = (await dynamicIconImports[name]()) as DynamicIconModule;
return icon.__iconNode;
}
/**
* Dynamic Lucide icon component
*
* @component Icon
* @param {object} props
* @param {string} props.color - The color of the icon
* @param {number} props.size - The size of the icon
* @param {number} props.strokeWidth - The stroke width of the icon
* @param {boolean} props.absoluteStrokeWidth - Whether to use absolute stroke width
* @param {string} props.className - The class name of the icon
* @param {IconNode} props.children - The children of the icon
* @param {IconNode} props.iconNode - The icon node of the icon
*
* @returns {ForwardRefExoticComponent} LucideIcon
*/
const DynamicIcon = forwardRef<SVGSVGElement, DynamicIconComponentProps>(
({ name, fallback: Fallback, ...props }, ref) => {
const [iconNode, setIconNode] = useState<IconNode>();
useEffect(() => {
getIconNode(name)
.then(setIconNode)
.catch((error) => {
console.error(error);
});
}, [name]);
if (iconNode == null) {
if (Fallback == null) {
return null;
}
return createElement(Fallback);
}
return createElement(Icon, {
ref,
...props,
iconNode,
});
},
);
export default DynamicIcon;

View File

@@ -0,0 +1,7 @@
export {
default as DynamicIcon,
iconNames,
type DynamicIconModule,
type IconName,
} from './DynamicIcon';
export { default as dynamicIconImports } from './dynamicIconImports';

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { act, render, waitFor, type RenderResult } from '@testing-library/react';
import DynamicIcon from '../src/DynamicIcon';
describe('Using DynamicIcon Component', () => {
it('should render icon by given name', async () => {
let container: RenderResult['container'];
await act(async () => {
const result = render(<DynamicIcon name="smile" />);
container = result.container;
});
await waitFor(() => {
// I'd look for a real text here that is renderer when the data loads
expect(container.firstChild).not.toBeNull();
});
});
it('should render icon by alias name', async () => {
let container: RenderResult['container'];
await act(async () => {
const result = render(<DynamicIcon name="home" />);
container = result.container;
});
await waitFor(() => {
// I'd look for a real text here that is renderer when the data loads
expect(container.firstChild).not.toBeNull();
});
});
it('should render icon and match snapshot', async () => {
const { container } = render(<DynamicIcon name="circle" />);
expect(container.firstChild).toMatchSnapshot();
});
it('should adjust the style based', async () => {
const { container } = render(
<DynamicIcon
name="circle"
size={48}
stroke="red"
absoluteStrokeWidth
/>,
);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Using DynamicIcon Component > should adjust the style based 1`] = `null`;
exports[`Using DynamicIcon Component > should render icon and match snapshot 1`] = `null`;