feat(icon-component): Creating icons with iconNodes (#1997)

* Add useIconComponent, lucide-react

* Add concept useIconComponent

* add useIconComponents to packages

* Add icon component

* Add icon component

* Add tests for react packages

* Reset changes in icons

* Add types

* Add support for Icon components in Lucide Vue Next

* update tests

* Update tests

* Enable Svelte component

* Fix lucide-react-native tests

* Update Solid package

* update snapshots

* Add docs

* add docs

* Update tests

* Formatting

* Formatting

* Update package lock

* Remove `useIconComponent`

* Update guides

* Update exports preact and solid package

* Formatting

* Format createIcons.ts

* Add lucide lab repo link in docs
This commit is contained in:
Eric Fennis
2024-04-26 17:59:04 +02:00
committed by GitHub
parent 65deefa53c
commit e50582e93e
77 changed files with 1705 additions and 428 deletions

View File

@@ -45,6 +45,7 @@
"devDependencies": {
"@lucide/rollup-plugins": "workspace:*",
"@lucide/build-icons": "workspace:*",
"@lucide/shared": "workspace:*",
"@testing-library/jest-dom": "^6.1.6",
"@testing-library/react": "^14.1.2",
"@types/prop-types": "^15.7.5",

View File

@@ -0,0 +1,69 @@
import { createElement, forwardRef, type FunctionComponent } from 'react';
import * as NativeSvg from 'react-native-svg';
import defaultAttributes, { childDefaultAttributes } from './defaultAttributes';
import { IconNode, LucideProps } from './types';
interface IconComponentProps extends LucideProps {
iconNode: IconNode;
}
/**
* 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 Icon = forwardRef<SVGSVGElement, IconComponentProps>(
(
{
color = 'currentColor',
size = 24,
strokeWidth = 2,
absoluteStrokeWidth,
children,
iconNode,
...rest
},
ref,
) => {
const customAttrs = {
stroke: color,
strokeWidth: absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth,
...rest,
};
return createElement(
NativeSvg.Svg as unknown as string,
{
ref,
...defaultAttributes,
width: size,
height: size,
...customAttrs,
},
[
...iconNode.map(([tag, attrs]) => {
const upperCasedTag = (tag.charAt(0).toUpperCase() +
tag.slice(1)) as keyof typeof NativeSvg;
// duplicating the attributes here because generating the OTA update bundles don't inherit the SVG properties from parent (codepush, expo-updates)
return createElement(
NativeSvg[upperCasedTag] as FunctionComponent<LucideProps>,
{ ...childDefaultAttributes, ...customAttrs, ...attrs } as LucideProps,
);
}),
...((Array.isArray(children) ? children : [children]) || []),
],
);
},
);
export default Icon;

View File

@@ -7,17 +7,7 @@ import {
} from 'react';
import * as NativeSvg from 'react-native-svg';
import defaultAttributes, { childDefaultAttributes } from './defaultAttributes';
import type { SvgProps } from 'react-native-svg';
export type IconNode = [elementName: keyof ReactSVG, attrs: Record<string, string>][];
export interface LucideProps extends SvgProps {
size?: string | number;
absoluteStrokeWidth?: boolean;
'data-testid'?: string;
}
export type LucideIcon = ForwardRefExoticComponent<LucideProps>;
import { IconNode, LucideIcon, LucideProps } from './types';
const createLucideIcon = (iconName: string, iconNode: IconNode): LucideIcon => {
const Component = forwardRef(

View File

@@ -1,9 +1,7 @@
export * from './icons';
export * as icons from './icons';
export * from './aliases';
export {
default as createLucideIcon,
type IconNode,
type LucideProps,
type LucideIcon,
} from './createLucideIcon';
export * from './types';
export { default as createLucideIcon } from './createLucideIcon';
export { default as Icon } from './Icon';

View File

@@ -0,0 +1,12 @@
import type { ForwardRefExoticComponent, ReactSVG } from 'react';
import type { SvgProps } from 'react-native-svg';
export type IconNode = [elementName: keyof ReactSVG, attrs: Record<string, string>][];
export interface LucideProps extends SvgProps {
size?: string | number;
absoluteStrokeWidth?: boolean;
'data-testid'?: string;
}
export type LucideIcon = ForwardRefExoticComponent<LucideProps>;

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { airVent } from './testIconNodes';
import { Icon } from '../src/lucide-react-native';
vi.mock('react-native-svg');
describe('Using Icon Component', () => {
it('should render icon based on a iconNode', async () => {
const { container } = render(
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
/>,
);
expect(container.firstChild).toBeDefined();
});
it('should render icon and match snapshot', async () => {
const { container } = render(
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
/>,
);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,48 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Using Icon Component > should render icon and match snapshot 1`] = `
<svg
fill="none"
height="48"
stroke="red"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
viewBox="0 0 24 24"
width="48"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"
fill="none"
stroke="red"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
/>
<path
d="M6 8h12"
fill="none"
stroke="red"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
/>
<path
d="M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12"
fill="none"
stroke="red"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
/>
<path
d="M6.6 15.6A2 2 0 1 0 10 17v-5"
fill="none"
stroke="red"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
/>
</svg>
`;

View File

@@ -10,7 +10,6 @@ exports[`Using lucide icon components > should adjust the size, stroke color and
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
data-testid="grid-icon"
>
<rect fill="none"
stroke="red"
@@ -128,7 +127,6 @@ exports[`Using lucide icon components > should not scale the strokeWidth when ab
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
data-testid="grid-icon"
>
<rect fill="none"
stroke="red"

View File

@@ -14,21 +14,20 @@ describe('Using lucide icon components', () => {
});
it('should adjust the size, stroke color and stroke width', () => {
const testId = 'grid-icon';
const { container, getByTestId } = render(
const { container } = render(
<Grid
data-testid={testId}
size={48}
stroke="red"
strokeWidth={4}
/>,
);
const { attributes } = getByTestId(testId);
expect((attributes as unknown as Attributes).stroke.value).toBe('red');
expect((attributes as unknown as Attributes).width.value).toBe('48');
expect((attributes as unknown as Attributes).height.value).toBe('48');
expect((attributes as unknown as Attributes)['stroke-width'].value).toBe('4');
const SVGElement = container.firstElementChild;
expect(SVGElement).toHaveAttribute('stroke', 'red');
expect(SVGElement).toHaveAttribute('width', '48');
expect(SVGElement).toHaveAttribute('height', '48');
expect(SVGElement).toHaveAttribute('stroke-width', '4');
expect(container.innerHTML).toMatchSnapshot();
});
@@ -61,23 +60,20 @@ describe('Using lucide icon components', () => {
});
it('should not scale the strokeWidth when absoluteStrokeWidth is set', () => {
const testId = 'grid-icon';
const { container, getByTestId } = render(
const { container } = render(
<Grid
data-testid={testId}
size={48}
stroke="red"
absoluteStrokeWidth
/>,
);
const { attributes } = getByTestId(testId) as unknown as {
attributes: Record<string, { value: string }>;
};
expect(attributes.stroke.value).toBe('red');
expect(attributes.width.value).toBe('48');
expect(attributes.height.value).toBe('48');
expect(attributes['stroke-width'].value).toBe('1');
const SVGElement = container.firstElementChild;
expect(SVGElement).toHaveAttribute('stroke', 'red');
expect(SVGElement).toHaveAttribute('width', '48');
expect(SVGElement).toHaveAttribute('height', '48');
expect(SVGElement).toHaveAttribute('stroke-width', '1');
expect(container.innerHTML).toMatchSnapshot();
});
@@ -91,8 +87,8 @@ describe('Using lucide icon components', () => {
<Grid data-testid={childId} />
</Grid>,
);
const { children } = getByTestId(testId) as unknown as { children: HTMLCollection };
const lastChild = children[children.length - 1];
const { children } = container.firstElementChild ?? {};
const lastChild = children?.[children.length - 1];
expect(lastChild).toEqual(getByTestId(childId));
expect(container.innerHTML).toMatchSnapshot();

View File

@@ -0,0 +1,22 @@
import { IconNode } from '../src/types';
export const airVent: IconNode = [
[
'path',
{
d: 'M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2',
key: 'larmp2',
},
],
['path', { d: 'M6 8h12', key: '6g4wlu' }],
['path', { d: 'M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12', key: '1bo8pg' }],
['path', { d: 'M6.6 15.6A2 2 0 1 0 10 17v-5', key: 't9h90c' }],
];
export const coffee: IconNode = [
['path', { d: 'M17 8h1a4 4 0 1 1 0 8h-1', key: 'jx4kbh' }],
['path', { d: 'M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z', key: '1bxrl0' }],
['line', { x1: '6', x2: '6', y1: '2', y2: '4', key: '1cr9l3' }],
['line', { x1: '10', x2: '10', y1: '2', y2: '4', key: '170wym' }],
['line', { x1: '14', x2: '14', y1: '2', y2: '4', key: '1c5f70' }],
];