diff --git a/.gitignore b/.gitignore index 691b0bc39..7aab235ad 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ packages/**/src/aliases.ts !packages/**/src/aliases/index.ts packages/**/src/utils/*.ts !packages/shared/src/utils/*.ts +!packages/shared/src/build/ +!packages/shared/src/build/*.ts packages/**/src/dynamicIconImports.ts packages/**/DynamicIcon.d.ts packages/**/dynamicIconImports.js diff --git a/packages/angular/package.json b/packages/angular/package.json index 622181ed7..7ba81a27e 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -17,14 +17,15 @@ "scripts": { "ng": "ng", "watch": "ng build --watch --configuration development", - "prebuild": "pnpm clean && pnpm copy:license && pnpm build:icons", + "prebuild": "pnpm clean && pnpm copy:license && pnpm copy:utils && pnpm build:icons", "build": "pnpm prebuild && pnpm build:ng", "copy:license": "cp ../../LICENSE ./LICENSE", + "copy:utils": "mkdir -p ./src/utils && cp -f ../../packages/shared/src/build/buildLucideIconNode.ts ./src/utils/ && cp -f ../../packages/shared/src/build/types.ts ./src/utils/", "clean": "rm -rf dist && rm -rf ./src/icons/*.ts", "build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --renderUniqueKey --iconFileExtension=.ts --exportFileName=lucide-angular.ts --useDefaultExports=0", "build:ng": "ng build --configuration production", - "test": "pnpm build:icons && ng test --no-watch", - "test:watch": "ng test", + "test": "pnpm copy:utils && pnpm build:icons && ng test --no-watch", + "test:watch": "pnpm copy:utils && ng test", "lint": "npx eslint 'src/**/*.{js,jsx,ts,tsx,html,css,scss}' --quiet --fix", "e2e": "ng e2e", "version": "pnpm version --git-tag-version=false" diff --git a/packages/angular/src/lucide-icon-base.ts b/packages/angular/src/lucide-icon-base.ts index 0775d0fbf..59fdb9667 100644 --- a/packages/angular/src/lucide-icon-base.ts +++ b/packages/angular/src/lucide-icon-base.ts @@ -12,6 +12,7 @@ import { LUCIDE_CONFIG } from './lucide-config'; import { LucideIconData, Nullable } from './types'; import defaultAttributes from './default-attributes'; import { lucideIconTemplate } from './lucide-icon-template'; +import buildLucideIconNode from './utils/buildLucideIconNode'; /** * @internal @@ -82,18 +83,27 @@ export abstract class LucideIconBase { const icon = this.icon(); if (icon) { const absoluteStrokeWidth = this.absoluteStrokeWidth(); - const { name, node, aliases = [] } = icon; - const classes = [name, ...aliases].map((item) => `lucide-${item}`); + const [, iconAttributes, iconNode] = buildLucideIconNode(icon as any, { + absoluteStrokeWidth, + hasA11yProp: this.title() != null, + }); + + const classes = + typeof iconAttributes['class'] === 'string' + ? iconAttributes['class'] + .split(' ') + .filter((cssClass) => cssClass && cssClass !== 'lucide') + : []; + for (const cssClass of classes) { this.renderer.addClass(this.elRef.nativeElement, cssClass); } + const contentRef = this.contentRef(); const refChild = contentRef.nativeElement; - const elements = node.map(([name, attrs]) => { + const elements = (iconNode ?? []).map(([name, attrs]) => { const element = this.renderer.createElement(name, 'http://www.w3.org/2000/svg'); - if (absoluteStrokeWidth) { - this.renderer.setAttribute(element, 'vector-effect', 'non-scaling-stroke'); - } + Object.entries(attrs).forEach(([name, value]) => this.renderer.setAttribute( element, diff --git a/packages/astro/scripts/exportTemplate.mts b/packages/astro/scripts/exportTemplate.mts index c8a41ed3a..1c43db3e4 100644 --- a/packages/astro/scripts/exportTemplate.mts +++ b/packages/astro/scripts/exportTemplate.mts @@ -1,28 +1,27 @@ import base64SVG from '@lucide/build-icons/utils/base64SVG'; import defineExportTemplate from '@lucide/build-icons/utils/defineExportTemplate'; -export default defineExportTemplate(async ({ - componentName, - iconName, - children, - getSvg, - deprecated, - deprecationReason, -}) => { - const svgContents = await getSvg(); - const svgBase64 = base64SVG(svgContents); +export default defineExportTemplate( + async ({ componentName, iconName, iconData, getSvg, deprecated, deprecationReason }) => { + const svgContents = await getSvg(); + const svgBase64 = base64SVG(svgContents); - // Astro doesn't need keyed children in loops - const keylessChildren = children.map((c) => { - const [element, { key, ...otherAttrs }] = c; - return [element, otherAttrs]; - }); + // Astro doesn't need keyed children in loops + const keylessIconData = { + ...iconData, + node: iconData.node.map((child) => { + const [element, { key, ...otherAttrs }] = child; + return [element, otherAttrs]; + }), + }; - // TODO: build-icons' `pretty` is set to false as the prettier - // formatter uses babel which I'm not sure it supports typescript - return ` + // TODO: build-icons' `pretty` is set to false as the prettier + // formatter uses babel which I'm not sure it supports typescript + return ` import createLucideIcon from '../createLucideIcon'; -import type { AstroComponent } from '../types' +import type { AstroComponent, LucideIconData } from '../types' + +const iconData: LucideIconData = ${JSON.stringify(keylessIconData)}; /** * @component @name ${componentName} @@ -35,8 +34,9 @@ import type { AstroComponent } from '../types' * @returns {any} Astro Component * ${deprecated ? `@deprecated ${deprecationReason}` : ''} */ -const ${componentName} = createLucideIcon('${iconName}', ${JSON.stringify(keylessChildren)}) as AstroComponent; +const ${componentName} = createLucideIcon(iconData) as AstroComponent; export default ${componentName}; `; -}); + }, +); diff --git a/packages/astro/src/Icon.astro b/packages/astro/src/Icon.astro index eb504dbad..df27db114 100644 --- a/packages/astro/src/Icon.astro +++ b/packages/astro/src/Icon.astro @@ -1,32 +1,50 @@ --- -import defaultAttributes from './defaultAttributes'; +import buildLucideIconNode from './utils/buildLucideIconNode'; import type { IconProps as Props } from './types'; import { hasA11yProp } from './utils/hasA11yProp'; - const { color = 'currentColor', size = 24, 'stroke-width': strokeWidth = 2, absoluteStrokeWidth = false, + name, + aliases, iconNode = [], class: className, ...rest } = Astro.props; + +const calculatedStrokeWidth = + absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth; + +const [, rawIconAttributes, builtIconNode = []] = buildLucideIconNode( + { + name, + aliases, + size: 24, + node: iconNode, + }, + { + color, + size, + strokeWidth: calculatedStrokeWidth, + className, + hasA11yProp: hasA11yProp(rest), + }, +); + +const { class: iconClassName, ...builtIconAttributes } = rawIconAttributes; +const iconAttributes = { + ...builtIconAttributes, + ...rest, +}; --- - {iconNode.map(([Tag, attrs]) => )} + {builtIconNode.map(([Tag, attrs]) => )} diff --git a/packages/astro/src/createLucideIcon.ts b/packages/astro/src/createLucideIcon.ts index 7ff99d5ec..22d12e942 100644 --- a/packages/astro/src/createLucideIcon.ts +++ b/packages/astro/src/createLucideIcon.ts @@ -1,11 +1,9 @@ import type { AstroComponentFactory } from 'astro/runtime/server/render/astro/factory.js'; -import type { IconNode } from './types'; +import type { LucideIconData } from './types'; import { render, renderSlot, createComponent, renderComponent } from 'astro/compiler-runtime'; import Icon from './Icon.astro'; -import { mergeClasses } from './utils/mergeClasses'; -import { toKebabCase } from './utils/toKebabCase'; -export default (iconName: string, iconNode: IconNode): AstroComponentFactory => { +export default (iconData: LucideIconData): AstroComponentFactory => { const Component = createComponent( ($$result, $$props: Record, $$slots) => { const { class: className, ...restProps } = $$props; @@ -14,11 +12,10 @@ export default (iconName: string, iconNode: IconNode): AstroComponentFactory => 'Icon', Icon, { - class: mergeClasses( - Boolean(iconName) && `lucide-${toKebabCase(iconName)}`, - Boolean(className) && className, - ), - iconNode, + class: className, + name: iconData.name, + aliases: iconData.aliases, + iconNode: iconData.node, ...restProps, }, { default: () => render`${renderSlot($$result, $$slots['default'])}` }, diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index 71f1d527f..d7ce5f411 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -9,6 +9,8 @@ export interface IconProps extends SVGAttributes { 'stroke-width'?: number | string; absoluteStrokeWidth?: boolean; class?: string; + name?: string; + aliases?: string[]; iconNode?: IconNode; title?: string; } @@ -17,6 +19,12 @@ export type SVGAttributes = HTMLAttributes<'svg'>; export type IconNode = IconNodeChild[]; +export interface LucideIconData { + name: string; + node: IconNode; + aliases?: string[]; +} + type IconNodeChild = [elementName: SVGElements, attrs: SVGAttributes]; // All possible svg elements according to the Astro definitions diff --git a/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap b/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap index a6ff7f2e4..6f9ff2921 100644 --- a/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap +++ b/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap @@ -47,7 +47,7 @@ exports[`Using lucide icon components > should adjust the size, stroke color and stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" - class="lucide lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" > should not scale the strokeWidth when ab stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" - class="lucide lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" > should render a component 1`] = ` stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" - class="lucide lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" > should render the icon with default attr stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" - class="lucide lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" > { it('should create a component from an iconNode', async () => { - const AirVent = createLucideIcon('AirVent', airVent); + const AirVent = createLucideIcon({ + name: 'air-vent', + node: airVent, + }); const { container } = await render(AirVent); expect(container.innerHTML).toBeDefined(); diff --git a/packages/icons/package.json b/packages/icons/package.json index 165cbd181..7f2e917e7 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -64,6 +64,7 @@ "@lucide/build-icons": "workspace:*", "@lucide/helpers": "^1.0.0", "@lucide/rollup-plugins": "workspace:*", + "@lucide/shared": "workspace:*", "@rollup/plugin-replace": "^6.0.2", "@testing-library/jest-dom": "^6.6.3", "jest-serializer-html": "^7.1.0", diff --git a/packages/icons/src/buildLucideDataUri.ts b/packages/icons/src/buildLucideDataUri.ts index 64aa7526c..eaa3dae1d 100644 --- a/packages/icons/src/buildLucideDataUri.ts +++ b/packages/icons/src/buildLucideDataUri.ts @@ -1,5 +1,5 @@ +import { buildLucideDataUri as sharedBuildLucideDataUri } from '@lucide/shared'; import { LucideBuildParams, LucideIconData } from './types'; -import buildLucideSvg from './buildLucideSvg'; /** * Creates a base64 encoded data URI from a Lucide icon object. @@ -8,23 +8,7 @@ import buildLucideSvg from './buildLucideSvg'; * @param params Additional build parameters. */ function buildLucideDataUri(icon: LucideIconData, params: LucideBuildParams = {}) { - const svg = buildLucideSvg(icon, params); - - // Browser - if (typeof btoa === 'function') { - // Ensure proper UTF-8 handling before base64 - const utf8Bytes = new TextEncoder().encode(svg); - let binary = ''; - for (const element of utf8Bytes) binary += String.fromCodePoint(element); - return `data:image/svg+xml;base64,${btoa(binary)}`; - } - - // Node.js (and other JS runtimes with Buffer) - if (typeof Buffer !== 'undefined') { - return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`; - } - - throw new Error('No base64 encoder available in this environment.'); + return sharedBuildLucideDataUri(icon as any, params as any); } export default buildLucideDataUri; diff --git a/packages/icons/src/buildLucideIconElement.ts b/packages/icons/src/buildLucideIconElement.ts index a819d0fde..c79f96ee2 100644 --- a/packages/icons/src/buildLucideIconElement.ts +++ b/packages/icons/src/buildLucideIconElement.ts @@ -1,20 +1,5 @@ -import { LucideBuildParams, LucideIconData, LucideIconNode } from './types'; -import buildLucideIconNode from './buildLucideIconNode'; - -const buildDomElement = ( - document: Document, - [tagName, attributes, children = []]: LucideIconNode, -): Element => { - const element = document.createElementNS('http://www.w3.org/2000/svg', tagName); - for (const [attrName, value] of Object.entries(attributes)) { - element.setAttribute(attrName, value); - } - for (const node of children) { - const childNode = buildDomElement(document, node); - element.appendChild(childNode); - } - return element; -}; +import { buildLucideIconElement as sharedBuildLucideIconElement } from '@lucide/shared'; +import { LucideBuildParams, LucideIconData } from './types'; /** * Creates an SvgElement from a Lucide icon object. @@ -28,7 +13,7 @@ function buildLucideIconElement( icon: LucideIconData, params: LucideBuildParams = {}, ) { - return buildDomElement(document, buildLucideIconNode(icon, params)); + return sharedBuildLucideIconElement(document, icon as any, params as any); } export default buildLucideIconElement; diff --git a/packages/icons/src/buildLucideIconNode.ts b/packages/icons/src/buildLucideIconNode.ts index a918ffbbd..9e29618dd 100644 --- a/packages/icons/src/buildLucideIconNode.ts +++ b/packages/icons/src/buildLucideIconNode.ts @@ -1,17 +1,6 @@ +import { buildLucideIconNode as sharedBuildLucideIconNode } from '@lucide/shared'; import { LucideBuildParams, LucideIconData, LucideIconNode } from './types'; -const defaultAttributes = { - xmlns: 'http://www.w3.org/2000/svg', - width: '24', - height: '24', - viewBox: '0 0 24 24', - fill: 'none', - stroke: 'currentColor', - 'stroke-width': '2', - 'stroke-linecap': 'round', - 'stroke-linejoin': 'round', -}; - /** * Creates a Lucide icon node (an svgson-like format) from a Lucide icon object. * @@ -19,37 +8,7 @@ const defaultAttributes = { * @param params Additional build parameters. */ function buildLucideIconNode(icon: LucideIconData, params: LucideBuildParams = {}): LucideIconNode { - const viewBoxWidth = ('size' in icon ? icon.size : icon.width) ?? defaultAttributes.width; - const viewBoxHeight = ('size' in icon ? icon.size : icon.height) ?? defaultAttributes.height; - const aliasClassNames = icon.aliases?.map((alias) => `lucide-${alias}`) ?? []; - const classList = ['lucide', `lucide-${icon.name}`, ...aliasClassNames]; - if (params['className']) { - classList.push(params['className']); - } - const attributes = { - ...defaultAttributes, - ...('color' in params && { stroke: params['color'] }), - ...('size' in params && - params['size'] && { - width: params['size'].toString(10), - height: params['size'].toString(10), - }), - ...('width' in params && params['width'] && { width: params['width'].toString(10) }), - ...('height' in params && params['height'] && { height: params['height'].toString(10) }), - ...('strokeWidth' in params && - params['strokeWidth'] && { 'stroke-width': params['strokeWidth'].toString(10) }), - class: classList.join(' '), - viewBox: `0 0 ${viewBoxWidth} ${viewBoxHeight}`, - ...('attributes' in params && params.attributes), - }; - return [ - 'svg', - attributes, - icon.node.map(([name, attrs]) => [ - name, - params['absoluteStrokeWidth'] ? { 'vector-effect': 'non-scaling-stroke', ...attrs } : attrs, - ]), - ]; + return sharedBuildLucideIconNode(icon as any, params as any) as LucideIconNode; } export default buildLucideIconNode; diff --git a/packages/icons/src/buildLucideSvg.ts b/packages/icons/src/buildLucideSvg.ts index 27bca4a91..197e1b626 100644 --- a/packages/icons/src/buildLucideSvg.ts +++ b/packages/icons/src/buildLucideSvg.ts @@ -1,10 +1,5 @@ -import { LucideBuildParams, LucideIconData, LucideIconNode } from './types'; -import buildLucideIconNode from './buildLucideIconNode'; - -const buildDomNode = ([tagName, attributes, children = []]: LucideIconNode): string => - `<${tagName} ${Object.entries(attributes) - .map(([attrName, value]) => `${attrName}="${value}"`) - .join(' ')}>${children?.map((child) => buildDomNode(child)).join('')}${tagName}>`; +import { buildLucideSvg as sharedBuildLucideSvg } from '@lucide/shared'; +import { LucideBuildParams, LucideIconData } from './types'; /** * Creates an SVG string from a Lucide icon object. @@ -13,7 +8,7 @@ const buildDomNode = ([tagName, attributes, children = []]: LucideIconNode): str * @param params Additional build parameters. */ function buildLucideSvg(icon: LucideIconData, params: LucideBuildParams = {}) { - return buildDomNode(buildLucideIconNode(icon, params)); + return sharedBuildLucideSvg(icon as any, params as any); } export default buildLucideSvg; diff --git a/packages/icons/src/types.ts b/packages/icons/src/types.ts index 563c93fd7..08e15ffa1 100644 --- a/packages/icons/src/types.ts +++ b/packages/icons/src/types.ts @@ -1,43 +1 @@ -export type SVGProps = Record; - -/** - * A Lucide icon node (an svgson-like internal format) - */ -export type LucideIconNode = - | [attrName: string, attributes: SVGProps] - | [attrName: string, attributes: SVGProps, children: LucideIconNode[]]; - -/** - * A Lucide icon object that fully describes an icon to be displayed. - */ -export type LucideIconData = { - name: string; - node: LucideIconNode[]; - aliases?: string[]; -} & ({ size: number } | { width: number; height: number }); - -/** - * Build parameters for creating a Lucide icon instance for display. - */ -export type LucideBuildParams = { - /** - * The color of the icon. - */ - color?: string; - /** - * The stroke width. - */ - strokeWidth?: number; - /** - * Adds [`vector-effect="non-scaling-stroke"`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/vector-effect) to child elements. - */ - absoluteStrokeWidth?: boolean; - /** - * Extra CSS class names to pass to the SVG element. - */ - className?: string; - /** - * Any extra attributes to pass to the SVG element. - */ - attributes?: SVGProps; -} & ({ size?: number } | { width?: number; height?: number }); +export type { LucideBuildParams, LucideIconData, LucideIconNode, SVGProps } from '@lucide/shared'; diff --git a/packages/icons/tests/buildLucideIconNode.spec.ts b/packages/icons/tests/buildLucideIconNode.spec.ts index dc3345ac5..5af66acbf 100644 --- a/packages/icons/tests/buildLucideIconNode.spec.ts +++ b/packages/icons/tests/buildLucideIconNode.spec.ts @@ -89,4 +89,30 @@ describe('buildLucideIconNode', () => { expect(HouseSVG[1]['class']).toBe('lucide lucide-house lucide-home icon'); }); + + it('should support className with multiple classes', () => { + const HouseSVG = buildLucideIconNode(House, { + className: ' icon custom lucide-house ', + }); + + expect(HouseSVG[1]['class']).toBe('lucide lucide-house lucide-home icon custom'); + }); + + it('should not include default classes when includeDefaultClasses is false', () => { + const HouseSVG = buildLucideIconNode(House, { includeDefaultClasses: false }); + + expect(HouseSVG[1]['class']).toBeUndefined(); + }); + + it('should add aria-hidden when hasA11yProp is false', () => { + const HouseSVG = buildLucideIconNode(House, { hasA11yProp: false }); + + expect(HouseSVG[1]['aria-hidden']).toBe('true'); + }); + + it('should not add aria-hidden when hasA11yProp is true', () => { + const HouseSVG = buildLucideIconNode(House, { hasA11yProp: true }); + + expect(HouseSVG[1]['aria-hidden']).toBeUndefined(); + }); }); diff --git a/packages/lucide-preact/scripts/exportTemplate.mts b/packages/lucide-preact/scripts/exportTemplate.mts index 28cc154bc..8eaa0baa6 100644 --- a/packages/lucide-preact/scripts/exportTemplate.mts +++ b/packages/lucide-preact/scripts/exportTemplate.mts @@ -1,19 +1,16 @@ import base64SVG from '@lucide/build-icons/utils/base64SVG'; import defineExportTemplate from '@lucide/build-icons/utils/defineExportTemplate'; -export default defineExportTemplate(async ({ - componentName, - iconName, - children, - getSvg, - deprecated, - deprecationReason, -}) => { - const svgContents = await getSvg(); - const svgBase64 = base64SVG(svgContents); +export default defineExportTemplate( + async ({ componentName, iconName, iconData, getSvg, deprecated, deprecationReason }) => { + const svgContents = await getSvg(); + const svgBase64 = base64SVG(svgContents); - return ` + return ` import createLucideIcon from '../createLucideIcon'; +import type { LucideIconData } from '../types'; + +const iconData: LucideIconData = ${JSON.stringify(iconData)}; /** * @component @name ${componentName} @@ -26,8 +23,9 @@ import createLucideIcon from '../createLucideIcon'; * @returns {JSX.Element} JSX Element * ${deprecated ? `@deprecated ${deprecationReason}` : ''} */ -const ${componentName} = createLucideIcon('${iconName}', ${JSON.stringify(children)}); +const ${componentName} = createLucideIcon(iconData); export default ${componentName}; `; -}); + }, +); diff --git a/packages/lucide-preact/src/Icon.ts b/packages/lucide-preact/src/Icon.ts index 0fb278848..07583e0b6 100644 --- a/packages/lucide-preact/src/Icon.ts +++ b/packages/lucide-preact/src/Icon.ts @@ -1,12 +1,13 @@ import { h, toChildArray } from 'preact'; +import { buildLucideIconNode, hasA11yProp, mergeClasses } from '@lucide/shared'; import defaultAttributes from './defaultAttributes'; -import type { IconNode, LucideProps } from './types'; +import type { LucideIconNode, LucideProps } from './types'; import { useLucideContext } from './context'; -import { mergeClasses } from '@lucide/shared'; -import { hasA11yProp } from '@lucide/shared'; interface IconComponentProps extends LucideProps { - iconNode: IconNode; + name?: string; + aliases?: string[]; + iconNode: LucideIconNode; } /** @@ -19,8 +20,8 @@ interface IconComponentProps extends LucideProps { * @param {number} props.strokeWidth - The stroke width of the icon * @param {boolean} props.absoluteStrokeWidth - Whether to use absolute stroke width * @param {string} props.class - 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 + * @param {LucideIconNode} props.children - The children of the icon + * @param {LucideIconNode} props.iconNode - The icon node of the icon * * @returns {ForwardRefExoticComponent} LucideIcon */ @@ -31,6 +32,8 @@ const Icon = ({ absoluteStrokeWidth, children, iconNode, + name, + aliases, class: classes = '', ...rest }: IconComponentProps) => { @@ -47,20 +50,28 @@ const Icon = ({ ? (Number(strokeWidth ?? contextStrokeWidth) * 24) / Number(size ?? contextSize) : strokeWidth ?? contextStrokeWidth; - return h( - 'svg', + const hasAccessibleProp = Boolean(children) || hasA11yProp(rest); + + const [, svgAttributes, builtIconNode = []] = buildLucideIconNode( { - ...defaultAttributes, - width: size ?? contextSize ?? 24, - height: size ?? contextSize ?? 24, - stroke: color ?? contextColor, - ['stroke-width' as 'strokeWidth']: calculatedStrokeWidth, - class: mergeClasses('lucide', contextClass, classes), - ...(!children && !hasA11yProp(rest) && { 'aria-hidden': 'true' }), - ...rest, + name, + aliases, + size: 24, + node: iconNode, + }, + { + color: color ?? contextColor, + size: size ?? contextSize ?? defaultAttributes.width, + strokeWidth: calculatedStrokeWidth, + className: mergeClasses(contextClass, classes as string), + hasA11yProp: hasAccessibleProp, }, - [...iconNode.map(([tag, attrs]) => h(tag, attrs)), ...toChildArray(children)], ); + + return h('svg', { ...svgAttributes, ...rest }, [ + ...builtIconNode.map(([tag, attrs]) => h(tag, attrs)), + ...toChildArray(children), + ]); }; export default Icon; diff --git a/packages/lucide-preact/src/createLucideIcon.ts b/packages/lucide-preact/src/createLucideIcon.ts index c5c14d4a3..5620bdad5 100644 --- a/packages/lucide-preact/src/createLucideIcon.ts +++ b/packages/lucide-preact/src/createLucideIcon.ts @@ -1,34 +1,56 @@ import { h, type JSX } from 'preact'; -import { mergeClasses, toKebabCase, toPascalCase } from '@lucide/shared'; +import { mergeClasses, toLucideIconData, toPascalCase } from '@lucide/shared'; import Icon from './Icon'; -import type { IconNode, LucideIcon, LucideProps } from './types'; +import type { LucideIcon, LucideIconData, LucideIconNode, LucideProps } from './types'; /** - * Create a Lucide icon component - * @param {string} iconName - * @param {array} iconNode - * @returns {FunctionComponent} LucideIcon + * Creates a Lucide icon component from icon data. + * + * @param iconData Icon data object. + * @returns Lucide icon component. */ -const createLucideIcon = (iconName: string, iconNode: IconNode): LucideIcon => { +function createLucideIcon(iconData: LucideIconData): LucideIcon; + +/** + * Creates a Lucide icon component from the legacy icon arguments. + * + * @param iconName Icon name. + * @param iconNode Icon node. + * @param aliases Optional icon aliases. + * @returns Lucide icon component. + */ +function createLucideIcon( + iconName: string, + iconNode: LucideIconNode, + aliases?: string[], +): LucideIcon; + +function createLucideIcon( + iconDataOrName: LucideIconData | string, + iconNode?: LucideIconNode, + aliases: string[] = [], +): LucideIcon { + const iconData = + typeof iconDataOrName === 'string' + ? toLucideIconData(iconDataOrName, iconNode as LucideIconNode, aliases) + : iconDataOrName; + const Component = ({ class: classes = '', className = '', children, ...props }: LucideProps) => h( Icon, { ...props, - iconNode, - class: mergeClasses>( - `lucide-${toKebabCase(toPascalCase(iconName))}`, - `lucide-${toKebabCase(iconName)}`, - classes, - className, - ), + name: iconData.name, + aliases: iconData.aliases, + iconNode: iconData.node, + class: mergeClasses>(classes, className), }, children, ); - Component.displayName = toPascalCase(iconName); + Component.displayName = toPascalCase(iconData.name); return Component; -}; +} export default createLucideIcon; diff --git a/packages/lucide-preact/src/types.ts b/packages/lucide-preact/src/types.ts index 03604e960..f9736e37e 100644 --- a/packages/lucide-preact/src/types.ts +++ b/packages/lucide-preact/src/types.ts @@ -1,6 +1,20 @@ import { type FunctionComponent, type JSX } from 'preact'; +import type { + LucideIconData as SharedLucideIconData, + LucideIconNode as SharedLucideIconNode, +} from '@lucide/shared'; -export type IconNode = [elementName: keyof JSX.IntrinsicElements, attrs: Record][]; +export type LucideIconNode = SharedLucideIconNode[]; + +export type LucideIconData = SharedLucideIconData & { + name: string; + node: LucideIconNode; +}; + +/** + * @deprecated Use LucideIconNode instead. + */ +export type IconNode = LucideIconNode; export interface LucideProps extends Partial> { color?: string; diff --git a/packages/lucide-preact/tests/__snapshots__/context.spec.tsx.snap b/packages/lucide-preact/tests/__snapshots__/context.spec.tsx.snap index b572910a9..85a5974f2 100644 --- a/packages/lucide-preact/tests/__snapshots__/context.spec.tsx.snap +++ b/packages/lucide-preact/tests/__snapshots__/context.spec.tsx.snap @@ -3,7 +3,7 @@ exports[`Using LucideProvider > should render the icon with LucideProvider 1`] = ` should create a component from an iconNode 1`] = ` +exports[`Using createLucideIcon > should create a component from icon data 1`] = ` should create a component from an iconNode 1`] `; -exports[`Using createLucideIcon > should create a component from an iconNode with iconName 1`] = ` +exports[`Using createLucideIcon > should include aliases from icon data 1`] = ` should create a component from an iconNode wit `; -exports[`Using createLucideIcon > should include backwards compatible className 1`] = ` +exports[`Using createLucideIcon > should merge className prop with icon classes 1`] = ` should adjust the size, stroke color and stroke-width="4" stroke-linecap="round" stroke-linejoin="round" - class="lucide lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" aria-hidden="true" > should not scale the strokeWidth when ab stroke-width="1" stroke-linecap="round" stroke-linejoin="round" - class="lucide lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" aria-hidden="true" > should render an component 1`] = ` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" - class="lucide lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" aria-hidden="true" > { const IconComponent = container.firstElementChild; - expect(IconComponent).toHaveAttribute('class', 'lucide provider-class lucide-house icon-class'); + expect(IconComponent).toHaveAttribute( + 'class', + 'lucide lucide-house lucide-home provider-class icon-class', + ); }); }); diff --git a/packages/lucide-preact/tests/createLucideIcon.spec.tsx b/packages/lucide-preact/tests/createLucideIcon.spec.tsx index bab081ff0..f2b20a952 100644 --- a/packages/lucide-preact/tests/createLucideIcon.spec.tsx +++ b/packages/lucide-preact/tests/createLucideIcon.spec.tsx @@ -4,30 +4,58 @@ import { airVent } from './testIconNodes'; import { render } from '@testing-library/preact'; describe('Using createLucideIcon', () => { - it('should create a component from an iconNode', () => { + it('should create a component from icon data', () => { + const AirVent = createLucideIcon({ + name: 'air-vent', + node: airVent, + }); + + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + expect(container.firstChild).toBeDefined(); + }); + + it('should include aliases from icon data', () => { + const CircleCheck = createLucideIcon({ + name: 'circle-check', + aliases: ['check-circle'], + node: airVent, + }); + + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + expect(container.firstChild).toBeDefined(); + }); + + it('should merge className prop with icon classes', () => { + const CircleCheck = createLucideIcon({ + name: 'circle-check', + aliases: ['check-circle'], + node: airVent, + }); + + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + expect(container.firstChild).toBeDefined(); + }); + + it('should create a component from legacy icon arguments', () => { const AirVent = createLucideIcon('AirVent', airVent); const { container } = render(); - expect(container.firstChild).toMatchSnapshot(); - expect(container.firstChild).toBeDefined(); + expect(container.firstChild).toHaveClass('lucide-air-vent'); }); - it('should create a component from an iconNode with iconName', () => { - const AirVent = createLucideIcon('air-vent', airVent); + it('should include aliases from legacy icon arguments', () => { + const CircleCheck = createLucideIcon('CircleCheck', airVent, ['check-circle']); - const { container } = render(); + const { container } = render(); - expect(container.firstChild).toMatchSnapshot(); - expect(container.firstChild).toBeDefined(); - }); - - it('should include backwards compatible className', () => { - const Layout2 = createLucideIcon('layout-2', airVent); - - const { container } = render(); - - expect(container.firstChild).toMatchSnapshot(); - expect(container.firstChild).toBeDefined(); + expect(container.firstChild).toHaveClass('lucide-circle-check'); + expect(container.firstChild).toHaveClass('lucide-check-circle'); }); }); diff --git a/packages/lucide-react-native/scripts/exportTemplate.mts b/packages/lucide-react-native/scripts/exportTemplate.mts index 19ed41dd9..c09cf5042 100644 --- a/packages/lucide-react-native/scripts/exportTemplate.mts +++ b/packages/lucide-react-native/scripts/exportTemplate.mts @@ -1,19 +1,16 @@ import base64SVG from '@lucide/build-icons/utils/base64SVG'; import defineExportTemplate from '@lucide/build-icons/utils/defineExportTemplate'; -export default defineExportTemplate(async ({ - componentName, - iconName, - children, - getSvg, - deprecated, - deprecationReason, -}) => { - const svgContents = await getSvg(); - const svgBase64 = base64SVG(svgContents); +export default defineExportTemplate( + async ({ componentName, iconName, iconData, getSvg, deprecated, deprecationReason }) => { + const svgContents = await getSvg(); + const svgBase64 = base64SVG(svgContents); - return ` + return ` import createLucideIcon from '../createLucideIcon'; +import type { LucideIconData } from '../types'; + +const iconData: LucideIconData = ${JSON.stringify(iconData)}; /** * @component @name ${componentName} @@ -26,8 +23,9 @@ import createLucideIcon from '../createLucideIcon'; * @returns {JSX.Element} JSX Element * ${deprecated ? `@deprecated ${deprecationReason}` : ''} */ -const ${componentName} = createLucideIcon('${componentName}', ${JSON.stringify(children)}); +const ${componentName} = createLucideIcon(iconData); export default ${componentName}; `; -}); + }, +); diff --git a/packages/lucide-react-native/src/Icon.ts b/packages/lucide-react-native/src/Icon.ts index e5c3e19e9..d00627b44 100644 --- a/packages/lucide-react-native/src/Icon.ts +++ b/packages/lucide-react-native/src/Icon.ts @@ -1,15 +1,39 @@ import { createElement, forwardRef, type FunctionComponent } from 'react'; import * as NativeSvg from 'react-native-svg'; +import { buildLucideIconNode } from '@lucide/shared'; import defaultAttributes, { childDefaultAttributes } from './defaultAttributes'; -import { IconNode, LucideProps } from './types'; +import { LucideIconNode, LucideProps } from './types'; import { useLucideContext } from './context'; interface IconComponentProps extends LucideProps { - iconNode: IconNode; + iconNode: LucideIconNode; testID?: string; className?: string; } +const toNativeSvgAttrName = (attributeName: string) => { + if (attributeName === 'class') { + return 'className'; + } + + if (attributeName.startsWith('data-') || attributeName.startsWith('aria-')) { + return attributeName; + } + + return attributeName.replace(/-([a-z])/g, (_, character: string) => character.toUpperCase()); +}; + +const toNativeSvgAttributes = (attributes: Record) => { + return Object.entries(attributes).reduce>((acc, [attributeName, value]) => { + if (value === undefined) { + return acc; + } + + acc[toNativeSvgAttrName(attributeName)] = value; + return acc; + }, {}); +}; + /** * Lucide icon component * @@ -20,8 +44,8 @@ interface IconComponentProps extends LucideProps { * @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 + * @param {LucideIconNode} props.children - The children of the icon + * @param {LucideIconNode} props.iconNode - The icon node of the icon * * @returns {ForwardRefExoticComponent} LucideIcon */ @@ -52,6 +76,20 @@ const Icon = forwardRef( ? (Number(strokeWidth ?? contextStrokeWidth) * 24) / Number(size ?? contextSize) : strokeWidth ?? contextStrokeWidth; + const [, svgAttributes, builtIconNode = []] = buildLucideIconNode( + { + size: 24, + node: iconNode, + }, + { + color: color ?? contextColor ?? defaultAttributes.stroke, + size: size ?? contextSize ?? defaultAttributes.width, + strokeWidth: calculatedStrokeWidth, + className, + includeDefaultClasses: false, + }, + ); + const customAttrs = { stroke: color ?? contextColor ?? defaultAttributes.stroke, strokeWidth: calculatedStrokeWidth, @@ -62,21 +100,22 @@ const Icon = forwardRef( NativeSvg.Svg as unknown as string, { ref, - ...defaultAttributes, - width: size ?? contextSize ?? defaultAttributes.width, - height: size ?? contextSize ?? defaultAttributes.height, + ...toNativeSvgAttributes(svgAttributes), 'data-testid': testID, - className, - ...customAttrs, + ...rest, }, [ - ...iconNode.map(([tag, attrs]) => { + ...builtIconNode.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, - { ...childDefaultAttributes, ...customAttrs, ...attrs } as LucideProps, + toNativeSvgAttributes({ + ...childDefaultAttributes, + ...customAttrs, + ...attrs, + }) as LucideProps, ); }), ...((Array.isArray(children) ? children : [children]) || []), diff --git a/packages/lucide-react-native/src/createLucideIcon.ts b/packages/lucide-react-native/src/createLucideIcon.ts index 4374b0b32..31f98f974 100644 --- a/packages/lucide-react-native/src/createLucideIcon.ts +++ b/packages/lucide-react-native/src/createLucideIcon.ts @@ -1,26 +1,51 @@ import { forwardRef, createElement } from 'react'; -import { IconNode, LucideProps } from './types'; -import { toPascalCase } from '@lucide/shared'; +import { LucideIcon, LucideIconData, LucideIconNode, LucideProps } from './types'; +import { toLucideIconData, toPascalCase } from '@lucide/shared'; import Icon from './Icon'; /** - * Create a Lucide icon component - * @param {string} iconName - * @param {array} iconNode - * @returns {ForwardRefExoticComponent} LucideIcon + * Creates a Lucide icon component from icon data. + * + * @param iconData Icon data object. + * @returns Lucide icon component. */ -const createLucideIcon = (iconName: string, iconNode: IconNode) => { +function createLucideIcon(iconData: LucideIconData): LucideIcon; + +/** + * Creates a Lucide icon component from the legacy icon arguments. + * + * @param iconName Icon name. + * @param iconNode Icon node. + * @param aliases Optional icon aliases. + * @returns Lucide icon component. + */ +function createLucideIcon( + iconName: string, + iconNode: LucideIconNode, + aliases?: string[], +): LucideIcon; + +function createLucideIcon( + iconDataOrName: LucideIconData | string, + iconNode?: LucideIconNode, + aliases: string[] = [], +): LucideIcon { + const iconData = + typeof iconDataOrName === 'string' + ? toLucideIconData(iconDataOrName, iconNode as LucideIconNode, aliases) + : iconDataOrName; + const Component = forwardRef((props, ref) => createElement(Icon, { ref, - iconNode, + iconNode: iconData.node, ...props, }), ); - Component.displayName = toPascalCase(iconName); + Component.displayName = toPascalCase(iconData.name); return Component; -}; +} export default createLucideIcon; diff --git a/packages/lucide-react-native/src/types.ts b/packages/lucide-react-native/src/types.ts index c7427bf4f..993c3b720 100644 --- a/packages/lucide-react-native/src/types.ts +++ b/packages/lucide-react-native/src/types.ts @@ -1,22 +1,21 @@ import type { ForwardRefExoticComponent } from 'react'; import type { SvgProps } from 'react-native-svg'; +import type { + LucideIconData as SharedLucideIconData, + LucideIconNode as SharedLucideIconNode, +} from '@lucide/shared'; + +export type LucideIconNode = SharedLucideIconNode[]; + +export type LucideIconData = SharedLucideIconData & { + name: string; + node: LucideIconNode; +}; /** - * A reduced version of `SVGElementType` from @types/react. This type was added - * with the release of React 19, and is included here in order to support usage - * with older versions. + * @deprecated Use LucideIconNode instead. */ -type SVGElementType = - | 'circle' - | 'ellipse' - | 'g' - | 'line' - | 'path' - | 'polygon' - | 'polyline' - | 'rect'; - -export type IconNode = [elementName: SVGElementType, attrs: Record][]; +export type IconNode = LucideIconNode; export interface LucideProps extends SvgProps { size?: string | number; diff --git a/packages/lucide-react/scripts/exportTemplate.mts b/packages/lucide-react/scripts/exportTemplate.mts index 02cfae27f..28baff98e 100644 --- a/packages/lucide-react/scripts/exportTemplate.mts +++ b/packages/lucide-react/scripts/exportTemplate.mts @@ -1,22 +1,16 @@ import base64SVG from '@lucide/build-icons/utils/base64SVG'; import defineExportTemplate from '@lucide/build-icons/utils/defineExportTemplate'; -export default defineExportTemplate(async({ - componentName, - iconName, - children, - getSvg, - deprecated, - deprecationReason, -}) => { - const svgContents = await getSvg(); - const svgBase64 = base64SVG(svgContents); +export default defineExportTemplate( + async ({ componentName, iconName, iconData, getSvg, deprecated, deprecationReason }) => { + const svgContents = await getSvg(); + const svgBase64 = base64SVG(svgContents); - return ` + return ` import createLucideIcon from '../createLucideIcon'; -import { IconNode } from '../types'; +import type { LucideIconData } from '../types'; -export const __iconNode: IconNode = ${JSON.stringify(children)} +export const __iconData: LucideIconData = ${JSON.stringify(iconData)} /** * @component @name ${componentName} @@ -29,8 +23,9 @@ export const __iconNode: IconNode = ${JSON.stringify(children)} * @returns {JSX.Element} JSX Element * ${deprecated ? `@deprecated ${deprecationReason}` : ''} */ -const ${componentName} = createLucideIcon('${iconName}', __iconNode); +const ${componentName} = createLucideIcon(__iconData); export default ${componentName}; `; -}); + }, +); diff --git a/packages/lucide-react/src/DynamicIcon.ts b/packages/lucide-react/src/DynamicIcon.ts index 773bab4f9..48e67dfc2 100644 --- a/packages/lucide-react/src/DynamicIcon.ts +++ b/packages/lucide-react/src/DynamicIcon.ts @@ -1,11 +1,15 @@ 'use client'; import { createElement, forwardRef, useEffect, useState } from 'react'; -import { IconNode, LucideIcon, LucideProps } from './types'; +import { LucideIcon, LucideIconData, LucideIconNode, LucideProps } from './types'; import dynamicIconImports from './dynamicIconImports'; import Icon from './Icon'; -export type DynamicIconModule = { default: LucideIcon; __iconNode: IconNode }; +export type DynamicIconModule = { + default: LucideIcon; + __iconNode?: LucideIconNode; + __iconData?: LucideIconData; +}; export type IconName = keyof typeof dynamicIconImports; @@ -24,7 +28,15 @@ async function getIconNode(name: IconName) { // TODO: Replace this with a generic iconNode package. const icon = (await dynamicIconImports[name]()) as DynamicIconModule; - return icon.__iconNode; + if (icon.__iconData != null) { + return icon.__iconData.node; + } + + if (icon.__iconNode != null) { + return icon.__iconNode; + } + + throw new Error('[lucide-react]: Failed to resolve icon data for DynamicIcon'); } /** @@ -37,14 +49,14 @@ async function getIconNode(name: IconName) { * @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 + * @param {LucideIconNode} props.children - The children of the icon + * @param {LucideIconNode} props.iconNode - The icon node of the icon * * @returns {ForwardRefExoticComponent} LucideIcon */ const DynamicIcon = forwardRef( ({ name, fallback: Fallback, ...props }, ref) => { - const [iconNode, setIconNode] = useState(); + const [iconNode, setIconNode] = useState(); useEffect(() => { getIconNode(name) diff --git a/packages/lucide-react/src/Icon.ts b/packages/lucide-react/src/Icon.ts index e958647c1..e4e7f010c 100644 --- a/packages/lucide-react/src/Icon.ts +++ b/packages/lucide-react/src/Icon.ts @@ -1,15 +1,40 @@ 'use client'; import { createElement, forwardRef } from 'react'; +import { buildLucideIconNode, hasA11yProp, mergeClasses } from '@lucide/shared'; import defaultAttributes from './defaultAttributes'; -import { IconNode, LucideProps } from './types'; -import { mergeClasses, hasA11yProp } from '@lucide/shared'; +import { LucideIconNode, LucideProps } from './types'; import { useLucideContext } from './context'; interface IconComponentProps extends LucideProps { - iconNode: IconNode; + name?: string; + aliases?: string[]; + iconNode: LucideIconNode; } +const toReactAttributeName = (attributeName: string) => { + if (attributeName === 'class') { + return 'className'; + } + + if (attributeName.startsWith('data-') || attributeName.startsWith('aria-')) { + return attributeName; + } + + return attributeName.replace(/-([a-z])/g, (_, character: string) => character.toUpperCase()); +}; + +const toReactAttributes = (attributes: Record) => { + return Object.entries(attributes).reduce>((acc, [attributeName, value]) => { + if (value === undefined) { + return acc; + } + + acc[toReactAttributeName(attributeName)] = value; + return acc; + }, {}); +}; + /** * Lucide icon component * @@ -20,14 +45,25 @@ interface IconComponentProps extends LucideProps { * @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 + * @param {LucideIconNode} props.children - The children of the icon + * @param {LucideIconNode} props.iconNode - The icon node of the icon * * @returns {ForwardRefExoticComponent} LucideIcon */ const Icon = forwardRef( ( - { color, size, strokeWidth, absoluteStrokeWidth, className = '', children, iconNode, ...rest }, + { + color, + size, + strokeWidth, + absoluteStrokeWidth, + className = '', + children, + iconNode, + name, + aliases, + ...rest + }, ref, ) => { const { @@ -43,21 +79,33 @@ const Icon = forwardRef( ? (Number(strokeWidth ?? contextStrokeWidth) * 24) / Number(size ?? contextSize) : strokeWidth ?? contextStrokeWidth; + const hasAccessibleProp = Boolean(children) || hasA11yProp(rest); + + const [, svgAttributes, builtIconNode = []] = buildLucideIconNode( + { + name, + aliases, + size: 24, + node: iconNode, + }, + { + color: color ?? contextColor, + size: size ?? contextSize ?? defaultAttributes.width, + strokeWidth: calculatedStrokeWidth, + className: mergeClasses(contextClass, className), + hasA11yProp: hasAccessibleProp, + }, + ); + return createElement( 'svg', { ref, - ...defaultAttributes, - width: size ?? contextSize ?? defaultAttributes.width, - height: size ?? contextSize ?? defaultAttributes.height, - stroke: color ?? contextColor, - strokeWidth: calculatedStrokeWidth, - className: mergeClasses('lucide', contextClass, className), - ...(!children && !hasA11yProp(rest) && { 'aria-hidden': 'true' }), + ...toReactAttributes(svgAttributes), ...rest, }, [ - ...iconNode.map(([tag, attrs]) => createElement(tag, attrs)), + ...builtIconNode.map(([tag, attrs]) => createElement(tag, attrs)), ...(Array.isArray(children) ? children : [children]), ], ); diff --git a/packages/lucide-react/src/createLucideIcon.ts b/packages/lucide-react/src/createLucideIcon.ts index bc453af84..1dc4d0785 100644 --- a/packages/lucide-react/src/createLucideIcon.ts +++ b/packages/lucide-react/src/createLucideIcon.ts @@ -1,31 +1,54 @@ import { createElement, forwardRef } from 'react'; -import { mergeClasses, toKebabCase, toPascalCase } from '@lucide/shared'; -import { IconNode, LucideProps } from './types'; +import { toLucideIconData, toPascalCase } from '@lucide/shared'; +import { LucideIcon, LucideIconData, LucideIconNode, LucideProps } from './types'; import Icon from './Icon'; /** - * Create a Lucide icon component - * @param {string} iconName - * @param {array} iconNode - * @returns {ForwardRefExoticComponent} LucideIcon + * Creates a Lucide icon component from icon data. + * + * @param iconData Icon data object. + * @returns Lucide icon component. */ -const createLucideIcon = (iconName: string, iconNode: IconNode) => { +function createLucideIcon(iconData: LucideIconData): LucideIcon; + +/** + * Creates a Lucide icon component from the legacy icon arguments. + * + * @param iconName Icon name. + * @param iconNode Icon node. + * @param aliases Optional icon aliases. + * @returns Lucide icon component. + */ +function createLucideIcon( + iconName: string, + iconNode: LucideIconNode, + aliases?: string[], +): LucideIcon; + +function createLucideIcon( + iconDataOrName: LucideIconData | string, + iconNode?: LucideIconNode, + aliases: string[] = [], +): LucideIcon { + const iconData = + typeof iconDataOrName === 'string' + ? toLucideIconData(iconDataOrName, iconNode as LucideIconNode, aliases) + : iconDataOrName; + const Component = forwardRef(({ className, ...props }, ref) => createElement(Icon, { ref, - iconNode, - className: mergeClasses( - `lucide-${toKebabCase(toPascalCase(iconName))}`, - `lucide-${iconName}`, - className, - ), + name: iconData.name, + aliases: iconData.aliases, + iconNode: iconData.node, + className, ...props, }), ); - Component.displayName = toPascalCase(iconName); + Component.displayName = toPascalCase(iconData.name); return Component; -}; +} export default createLucideIcon; diff --git a/packages/lucide-react/src/types.ts b/packages/lucide-react/src/types.ts index f57229224..c58b6c8ff 100644 --- a/packages/lucide-react/src/types.ts +++ b/packages/lucide-react/src/types.ts @@ -1,21 +1,20 @@ import type { SVGProps, ForwardRefExoticComponent, RefAttributes } from 'react'; +import type { + LucideIconData as SharedLucideIconData, + LucideIconNode as SharedLucideIconNode, +} from '@lucide/shared'; + +export type LucideIconNode = SharedLucideIconNode[]; + +export type LucideIconData = SharedLucideIconData & { + name: string; + node: LucideIconNode; +}; /** - * A reduced version of `SVGElementType` from @types/react. This type was added - * with the release of React 19, and is included here in order to support usage - * with older versions. + * @deprecated Use LucideIconNode instead. */ -type SVGElementType = - | 'circle' - | 'ellipse' - | 'g' - | 'line' - | 'path' - | 'polygon' - | 'polyline' - | 'rect'; - -export type IconNode = [elementName: SVGElementType, attrs: Record][]; +export type IconNode = LucideIconNode; export type SVGAttributes = Partial>; type ElementAttributes = RefAttributes & SVGAttributes; diff --git a/packages/lucide-react/tests/__snapshots__/context.spec.tsx.snap b/packages/lucide-react/tests/__snapshots__/context.spec.tsx.snap index b572910a9..85a5974f2 100644 --- a/packages/lucide-react/tests/__snapshots__/context.spec.tsx.snap +++ b/packages/lucide-react/tests/__snapshots__/context.spec.tsx.snap @@ -3,7 +3,7 @@ exports[`Using LucideProvider > should render the icon with LucideProvider 1`] = ` should create a component from an iconNode 1`] = ` - - - - - - -`; - -exports[`Using createLucideIcon > should create a component from an iconNode with iconName 1`] = ` +exports[`Using createLucideIcon > should create a component from icon data 1`] = ` should create a component from an iconNode wit `; -exports[`Using createLucideIcon > should include backwards compatible className 1`] = ` +exports[`Using createLucideIcon > should include aliases from icon data 1`] = ` + + + + + +`; + +exports[`Using createLucideIcon > should merge className prop with icon classes 1`] = ` + should adjust the size, stroke color and stroke-width="4" stroke-linecap="round" stroke-linejoin="round" - class="lucide lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" aria-hidden="true" > should not scale the strokeWidth when ab stroke-width="1" stroke-linecap="round" stroke-linejoin="round" - class="lucide lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" aria-hidden="true" > should render an component 1`] = ` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" - class="lucide lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" aria-hidden="true" > { const IconComponent = container.firstElementChild; - expect(IconComponent).toHaveAttribute('class', 'lucide provider-class lucide-house icon-class'); + expect(IconComponent).toHaveAttribute( + 'class', + 'lucide lucide-house lucide-home provider-class icon-class', + ); }); }); diff --git a/packages/lucide-react/tests/createLucideIcon.spec.tsx b/packages/lucide-react/tests/createLucideIcon.spec.tsx index 051bc579e..5339b05a5 100644 --- a/packages/lucide-react/tests/createLucideIcon.spec.tsx +++ b/packages/lucide-react/tests/createLucideIcon.spec.tsx @@ -4,30 +4,58 @@ import { airVent } from './testIconNodes'; import { render } from '@testing-library/react'; describe('Using createLucideIcon', () => { - it('should create a component from an iconNode', () => { + it('should create a component from icon data', () => { + const AirVent = createLucideIcon({ + name: 'air-vent', + node: airVent, + }); + + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + expect(container.firstChild).toBeDefined(); + }); + + it('should include aliases from icon data', () => { + const CircleCheck = createLucideIcon({ + name: 'circle-check', + aliases: ['check-circle'], + node: airVent, + }); + + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + expect(container.firstChild).toBeDefined(); + }); + + it('should merge className prop with icon classes', () => { + const CircleCheck = createLucideIcon({ + name: 'circle-check', + aliases: ['check-circle'], + node: airVent, + }); + + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + expect(container.firstChild).toBeDefined(); + }); + + it('should create a component from legacy icon arguments', () => { const AirVent = createLucideIcon('AirVent', airVent); const { container } = render(); - expect(container.firstChild).toMatchSnapshot(); - expect(container.firstChild).toBeDefined(); + expect(container.firstChild).toHaveClass('lucide-air-vent'); }); - it('should create a component from an iconNode with iconName', () => { - const AirVent = createLucideIcon('air-vent', airVent); + it('should include aliases from legacy icon arguments', () => { + const CircleCheck = createLucideIcon('CircleCheck', airVent, ['check-circle']); - const { container } = render(); + const { container } = render(); - expect(container.firstChild).toMatchSnapshot(); - expect(container.firstChild).toBeDefined(); - }); - - it('should include backwards compatible className', () => { - const Layout2 = createLucideIcon('layout-2', airVent); - - const { container } = render(); - - expect(container.firstChild).toMatchSnapshot(); - expect(container.firstChild).toBeDefined(); + expect(container.firstChild).toHaveClass('lucide-circle-check'); + expect(container.firstChild).toHaveClass('lucide-check-circle'); }); }); diff --git a/packages/lucide-solid/scripts/exportTemplate.mts b/packages/lucide-solid/scripts/exportTemplate.mts index 4a686a69b..8bf2bb3c1 100644 --- a/packages/lucide-solid/scripts/exportTemplate.mts +++ b/packages/lucide-solid/scripts/exportTemplate.mts @@ -1,22 +1,16 @@ import base64SVG from '@lucide/build-icons/utils/base64SVG'; import defineExportTemplate from '@lucide/build-icons/utils/defineExportTemplate'; -export default defineExportTemplate(async ({ - componentName, - iconName, - children, - getSvg, - deprecated, - deprecationReason, -}) => { - const svgContents = await getSvg(); - const svgBase64 = base64SVG(svgContents); +export default defineExportTemplate( + async ({ componentName, iconName, iconData, getSvg, deprecated, deprecationReason }) => { + const svgContents = await getSvg(); + const svgBase64 = base64SVG(svgContents); - return ` + return ` import Icon from '../Icon'; -import type { IconNode, LucideProps } from '../types'; +import type { LucideIconData, LucideProps } from '../types'; -const iconNode: IconNode = ${JSON.stringify(children)}; +const iconData: LucideIconData = ${JSON.stringify(iconData)}; /** * @component @name ${componentName} @@ -30,9 +24,10 @@ const iconNode: IconNode = ${JSON.stringify(children)}; * ${deprecated ? `@deprecated ${deprecationReason}` : ''} */ const ${componentName} = (props: LucideProps) => ( - + ) export default ${componentName}; `; -}); + }, +); diff --git a/packages/lucide-solid/src/Icon.tsx b/packages/lucide-solid/src/Icon.tsx index bda2b465f..51df3fe23 100644 --- a/packages/lucide-solid/src/Icon.tsx +++ b/packages/lucide-solid/src/Icon.tsx @@ -1,12 +1,13 @@ import { For, splitProps, useContext } from 'solid-js'; import { Dynamic } from 'solid-js/web'; +import { buildLucideIconNode, hasA11yProp, mergeClasses } from '@lucide/shared'; import defaultAttributes from './defaultAttributes'; import { IconNode, LucideProps } from './types'; import { LucideContext } from './context'; -import { hasA11yProp, mergeClasses, toKebabCase, toPascalCase } from '@lucide/shared'; interface IconProps { name?: string; + aliases?: string[]; iconNode: IconNode; } @@ -18,49 +19,60 @@ const Icon = (props: LucideProps & IconProps) => { 'children', 'class', 'name', + 'aliases', 'iconNode', 'absoluteStrokeWidth', ]); const globalProps = useContext(LucideContext); + const calculatedStrokeWidth = + (localProps.absoluteStrokeWidth ?? globalProps.absoluteStrokeWidth) === true + ? (Number( + localProps.strokeWidth ?? globalProps.strokeWidth ?? defaultAttributes['stroke-width'], + ) * + 24) / + Number(localProps.size ?? globalProps.size) + : Number( + localProps.strokeWidth ?? globalProps.strokeWidth ?? defaultAttributes['stroke-width'], + ); + + const hasAccessibleProp = Boolean(localProps.children) || hasA11yProp(rest); + + const [, svgAttributes, builtIconNode = []] = buildLucideIconNode( + { + name: localProps.name, + aliases: localProps.aliases, + size: 24, + node: localProps.iconNode, + }, + { + color: localProps.color ?? globalProps.color ?? defaultAttributes.stroke, + size: localProps.size ?? globalProps.size ?? defaultAttributes.width, + strokeWidth: calculatedStrokeWidth, + className: mergeClasses('lucide-icon', globalProps.class, localProps.class), + hasA11yProp: hasAccessibleProp, + }, + ); + + const svgElementAttributes = { + xmlns: svgAttributes.xmlns, + viewBox: svgAttributes.viewBox, + fill: svgAttributes.fill, + 'stroke-linecap': svgAttributes['stroke-linecap'], + 'stroke-linejoin': svgAttributes['stroke-linejoin'], + width: svgAttributes.width, + height: svgAttributes.height, + stroke: svgAttributes.stroke, + 'stroke-width': svgAttributes['stroke-width'], + class: svgAttributes.class, + 'aria-hidden': svgAttributes['aria-hidden'], + ...rest, + }; + return ( - - + + {([elementName, attrs]) => { return ( ][]; +export interface LucideIconData { + name: string; + node: IconNode; + aliases?: string[]; +} export type SVGAttributes = Partial>; export interface LucideProps extends SVGAttributes { diff --git a/packages/lucide-solid/tests/__snapshots__/context.spec.tsx.snap b/packages/lucide-solid/tests/__snapshots__/context.spec.tsx.snap index fa15d5ffd..723407f5f 100644 --- a/packages/lucide-solid/tests/__snapshots__/context.spec.tsx.snap +++ b/packages/lucide-solid/tests/__snapshots__/context.spec.tsx.snap @@ -3,7 +3,7 @@ exports[`Using LucideProvider > should render the icon with LucideProvider 1`] = ` should adjust the size, stroke color and height="48" stroke="red" stroke-width="4" - class="lucide lucide-icon lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3 lucide-icon" aria-hidden="true" data-testid="grid-icon" > @@ -51,7 +51,7 @@ exports[`Using lucide icon components > should not scale the strokeWidth when ab height="48" stroke="red" stroke-width="1" - class="lucide lucide-icon lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3 lucide-icon" aria-hidden="true" data-testid="grid-icon" > @@ -92,7 +92,7 @@ exports[`Using lucide icon components > should render a component 1`] = ` height="24" stroke="currentColor" stroke-width="2" - class="lucide lucide-icon lucide-grid3x3 lucide-grid-3x3" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3 lucide-icon" aria-hidden="true" > { expect(IconComponent).toHaveAttribute( 'class', - 'lucide lucide-icon provider-class lucide-house icon-class', + 'lucide lucide-house lucide-home lucide-icon provider-class icon-class', ); }); }); diff --git a/packages/lucide/src/createElement.ts b/packages/lucide/src/createElement.ts index f28e585fc..b19c5c5d5 100644 --- a/packages/lucide/src/createElement.ts +++ b/packages/lucide/src/createElement.ts @@ -1,33 +1,6 @@ -import defaultAttributes from './defaultAttributes'; +import { buildLucideIconElement } from '@lucide/shared'; import { IconNode, SVGProps } from './types'; -type CreateSVGElementParams = [tag: string, attrs: SVGProps, children?: IconNode]; - -/** - * Creates a new SVGElement - * @param {string} tag - Tag name of the element - * @param {object} attrs - Attributes of the element - * @param {array} children - Children of the element - * @returns {SVGElement} - */ -const createSVGElement = ([tag, attrs, children]: CreateSVGElementParams) => { - const element = document.createElementNS('http://www.w3.org/2000/svg', tag); - - Object.keys(attrs).forEach((name) => { - element.setAttribute(name, String(attrs[name])); - }); - - if (children?.length) { - children.forEach((child) => { - const childElement = createSVGElement(child); - - element.appendChild(childElement); - }); - } - - return element; -}; - /** * Creates a new HTMLElement from icon node * @param {array} iconNode - Icon node to be converted to an element @@ -35,13 +8,17 @@ const createSVGElement = ([tag, attrs, children]: CreateSVGElementParams) => { * @returns {HTMLElement} */ const createElement = (iconNode: IconNode, customAttrs: SVGProps = {}) => { - const tag = 'svg'; - const attrs = { - ...defaultAttributes, - ...customAttrs, - }; - - return createSVGElement([tag, attrs, iconNode]); + return buildLucideIconElement( + document, + { + size: 24, + node: iconNode, + }, + { + includeDefaultClasses: false, + attributes: customAttrs, + }, + ) as SVGElement; }; export default createElement; diff --git a/packages/lucide/src/replaceElement.ts b/packages/lucide/src/replaceElement.ts index 69ba89712..a686cb4fc 100644 --- a/packages/lucide/src/replaceElement.ts +++ b/packages/lucide/src/replaceElement.ts @@ -1,7 +1,6 @@ -import createElement from './createElement'; -import defaultAttributes from './defaultAttributes'; -import { Icons, SVGProps } from './types'; import { hasA11yProp, mergeClasses, toPascalCase } from '@lucide/shared'; +import createElement from './createElement'; +import { Icons, SVGProps } from './types'; export type CustomAttrs = { [attr: string]: any }; @@ -63,15 +62,15 @@ const replaceElement = (element: Element, { nameAttr, icons, attrs }: ReplaceEle } const elementAttrs = getAttrs(element); - const ariaProps = hasA11yProp(elementAttrs) ? {} : { 'aria-hidden': 'true' }; + const { class: _attrsClassName, ...attrsWithoutClass } = attrs; + const { class: _elementClassName, ...elementAttrsWithoutClass } = elementAttrs; const iconAttrs = { - ...defaultAttributes, 'data-lucide': iconName, ...ariaProps, - ...attrs, - ...elementAttrs, + ...attrsWithoutClass, + ...elementAttrsWithoutClass, } satisfies SVGProps; const elementClassNames = getClassNames(elementAttrs); diff --git a/packages/shared/src/build/buildLucideDataUri.ts b/packages/shared/src/build/buildLucideDataUri.ts new file mode 100644 index 000000000..285403350 --- /dev/null +++ b/packages/shared/src/build/buildLucideDataUri.ts @@ -0,0 +1,30 @@ +import type { LucideBuildParams, LucideIconData } from './types'; +import buildLucideSvg from './buildLucideSvg'; + +/** + * Creates a base64 encoded data URI from a Lucide icon object. + * + * @param icon The icon to build. + * @param params Additional build parameters. + */ +function buildLucideDataUri(icon: LucideIconData, params: LucideBuildParams = {}) { + const svg = buildLucideSvg(icon, params); + + // Browser + if (typeof btoa === 'function') { + // Ensure proper UTF-8 handling before base64 + const utf8Bytes = new TextEncoder().encode(svg); + let binary = ''; + for (const element of utf8Bytes) binary += String.fromCodePoint(element); + return `data:image/svg+xml;base64,${btoa(binary)}`; + } + + // Node.js (and other JS runtimes with Buffer) + if (typeof Buffer !== 'undefined') { + return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`; + } + + throw new Error('No base64 encoder available in this environment.'); +} + +export default buildLucideDataUri; diff --git a/packages/shared/src/build/buildLucideIconElement.ts b/packages/shared/src/build/buildLucideIconElement.ts new file mode 100644 index 000000000..0ac863fa6 --- /dev/null +++ b/packages/shared/src/build/buildLucideIconElement.ts @@ -0,0 +1,36 @@ +import buildLucideIconNode from './buildLucideIconNode'; +import type { LucideBuildParams, LucideIconData, LucideIconNode } from './types'; + +const buildDomElement = ( + document: Document, + [tagName, attributes, children = []]: LucideIconNode, +): Element => { + const element = document.createElementNS('http://www.w3.org/2000/svg', tagName); + for (const [attrName, value] of Object.entries(attributes)) { + if (value !== undefined && value !== null) { + element.setAttribute(attrName, String(value)); + } + } + for (const node of children) { + const childNode = buildDomElement(document, node); + element.appendChild(childNode); + } + return element; +}; + +/** + * Creates an SvgElement from a Lucide icon object. + * + * @param document The document to create the Element in. + * @param icon The icon to build. + * @param params Additional build parameters. + */ +function buildLucideIconElement( + document: Document, + icon: LucideIconData, + params: LucideBuildParams = {}, +) { + return buildDomElement(document, buildLucideIconNode(icon, params)); +} + +export default buildLucideIconElement; diff --git a/packages/shared/src/build/buildLucideIconNode.ts b/packages/shared/src/build/buildLucideIconNode.ts new file mode 100644 index 000000000..cd0b37713 --- /dev/null +++ b/packages/shared/src/build/buildLucideIconNode.ts @@ -0,0 +1,89 @@ +import type { LucideBuildParams, LucideIconData, LucideIconNode, SVGProps } from './types'; + +export type { LucideBuildParams, LucideIconData, LucideIconNode, SVGProps } from './types'; + +const defaultAttributes: SVGProps = { + xmlns: 'http://www.w3.org/2000/svg', + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', +}; + +const mergeClasses = (...classes: Array) => + classes + .filter( + (className, index, array): className is string => + typeof className === 'string' && + className.trim() !== '' && + array.indexOf(className) === index, + ) + .join(' ') + .trim(); + +const toString = (value: string | number) => + typeof value === 'number' ? value.toString(10) : value.toString(); + +/** + * Creates a Lucide icon node (an svgson-like format) from a Lucide icon object. + * + * @param icon The icon to build. + * @param params Additional build parameters. + */ +function buildLucideIconNode(icon: LucideIconData, params: LucideBuildParams = {}): LucideIconNode { + const viewBoxWidth = + ('size' in icon && icon.size !== undefined + ? icon.size + : 'width' in icon + ? icon.width + : undefined) ?? defaultAttributes['width']; + const viewBoxHeight = + ('size' in icon && icon.size !== undefined + ? icon.size + : 'height' in icon + ? icon.height + : undefined) ?? defaultAttributes['height']; + const aliasClassNames = + icon.aliases + ?.filter((alias): alias is string => typeof alias === 'string' && alias.trim() !== '') + .map((alias) => `lucide-${alias}`) ?? []; + const iconClassNames = [...(icon.name ? [`lucide-${icon.name}`] : []), ...aliasClassNames]; + const classNamesFromClassName = params.className?.split(' ').filter(Boolean) ?? []; + const className = + params.includeDefaultClasses === false + ? mergeClasses(...classNamesFromClassName) + : mergeClasses('lucide', ...iconClassNames, ...classNamesFromClassName); + + const attributes = { + ...defaultAttributes, + ...('color' in params && params.color && { stroke: params.color }), + ...('size' in params && + params.size && { + width: toString(params.size), + height: toString(params.size), + }), + ...('width' in params && params.width && { width: toString(params.width) }), + ...('height' in params && params.height && { height: toString(params.height) }), + ...('strokeWidth' in params && + params.strokeWidth && { 'stroke-width': toString(params.strokeWidth) }), + ...(className && { class: className }), + viewBox: `0 0 ${viewBoxWidth} ${viewBoxHeight}`, + ...(params.hasA11yProp === false ? { 'aria-hidden': 'true' } : {}), + ...('attributes' in params && params.attributes), + }; + + return [ + 'svg', + attributes, + icon.node.map(([name, attrs]) => [ + name, + params.absoluteStrokeWidth ? { 'vector-effect': 'non-scaling-stroke', ...attrs } : attrs, + ]), + ]; +} + +export default buildLucideIconNode; diff --git a/packages/shared/src/build/buildLucideSvg.ts b/packages/shared/src/build/buildLucideSvg.ts new file mode 100644 index 000000000..f2bc8b8fc --- /dev/null +++ b/packages/shared/src/build/buildLucideSvg.ts @@ -0,0 +1,20 @@ +import buildLucideIconNode from './buildLucideIconNode'; +import type { LucideBuildParams, LucideIconData, LucideIconNode } from './types'; + +const buildDomNode = ([tagName, attributes, children = []]: LucideIconNode): string => + `<${tagName} ${Object.entries(attributes) + .filter(([, value]) => value !== undefined && value !== null) + .map(([attrName, value]) => `${attrName}="${String(value)}"`) + .join(' ')}>${children?.map((child) => buildDomNode(child)).join('')}${tagName}>`; + +/** + * Creates an SVG string from a Lucide icon object. + * + * @param icon The icon to build. + * @param params Additional build parameters. + */ +function buildLucideSvg(icon: LucideIconData, params: LucideBuildParams = {}) { + return buildDomNode(buildLucideIconNode(icon, params)); +} + +export default buildLucideSvg; diff --git a/packages/shared/src/build/types.ts b/packages/shared/src/build/types.ts new file mode 100644 index 000000000..149d6ef37 --- /dev/null +++ b/packages/shared/src/build/types.ts @@ -0,0 +1,54 @@ +export type SVGProps = Record; + +/** + * A Lucide icon node (an svgson-like internal format) + */ +export type LucideIconNode = + | [attrName: string, attributes: SVGProps] + | [attrName: string, attributes: SVGProps, children: LucideIconNode[]]; + +/** + * A Lucide icon object that fully describes an icon to be displayed. + */ +export type LucideIconData = { + name?: string; + node: LucideIconNode[]; + aliases?: string[]; +} & ({ size?: number } | { width?: number; height?: number }); + +/** + * Build parameters for creating a Lucide icon instance for display. + */ +export type LucideBuildParams = { + /** + * The color of the icon. + */ + color?: string; + /** + * The stroke width. + */ + strokeWidth?: string | number; + /** + * Adds [`vector-effect="non-scaling-stroke"`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/vector-effect) to child elements. + */ + absoluteStrokeWidth?: boolean; + /** + * Extra CSS class names to pass to the SVG element. + */ + className?: string; + /** + * Include default Lucide classes on the SVG element. + * + * @default true + */ + includeDefaultClasses?: boolean; + /** + * Whether the rendered icon already has accessibility props. + * If false, `aria-hidden="true"` is added automatically. + */ + hasA11yProp?: boolean; + /** + * Any extra attributes to pass to the SVG element. + */ + attributes?: SVGProps; +} & ({ size?: string | number } | { width?: string | number; height?: string | number }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ca8a3aa23..212c86831 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,7 +1,13 @@ +export { default as buildLucideDataUri } from './build/buildLucideDataUri'; +export { default as buildLucideIconElement } from './build/buildLucideIconElement'; +export { default as buildLucideIconNode } from './build/buildLucideIconNode'; +export { default as buildLucideSvg } from './build/buildLucideSvg'; +export type { LucideBuildParams, LucideIconData, LucideIconNode, SVGProps } from './build/types'; export * from './utils/hasA11yProp'; export * from './utils/isEmptyString'; export * from './utils/mergeClasses'; export * from './utils/toCamelCase'; export * from './utils/toKebabCase'; +export * from './utils/toLucideIconData'; export * from './utils/toPascalCase'; export * from './utility-types'; diff --git a/packages/shared/src/utils/toLucideIconData.ts b/packages/shared/src/utils/toLucideIconData.ts new file mode 100644 index 000000000..13d450f95 --- /dev/null +++ b/packages/shared/src/utils/toLucideIconData.ts @@ -0,0 +1,22 @@ +import { toKebabCase } from './toKebabCase'; +import type { LucideIconData, LucideIconNode } from '../build/types'; + +/** + * Converts legacy `(name, iconNode, aliases?)` icon arguments into icon data object format. + */ +export function toLucideIconData( + iconName: string, + iconNode: LucideIconNode[], + aliases: string[] = [], +): LucideIconData { + if (iconNode == null) { + throw new Error('[lucide]: iconNode is required when icon name is used'); + } + + return { + name: toKebabCase(iconName), + size: 24, + node: iconNode, + ...(aliases.length > 0 ? { aliases } : {}), + }; +} diff --git a/packages/shared/tests/toLucideIconData.spec.ts b/packages/shared/tests/toLucideIconData.spec.ts new file mode 100644 index 000000000..b08c79f07 --- /dev/null +++ b/packages/shared/tests/toLucideIconData.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { toLucideIconData } from '../src/utils/toLucideIconData'; + +describe('toLucideIconData', () => { + it('converts legacy name and node to icon data object', () => { + const iconNode = [['path', { d: 'M0 0h1' }]]; + + expect(toLucideIconData('AirVent', iconNode)).toEqual({ + name: 'air-vent', + size: 24, + node: iconNode, + }); + }); + + it('adds aliases when provided for legacy input', () => { + const iconNode = [['path', { d: 'M0 0h1' }]]; + + expect(toLucideIconData('AirVent', iconNode, ['vent'])).toEqual({ + name: 'air-vent', + size: 24, + node: iconNode, + aliases: ['vent'], + }); + }); + + it('throws when icon node is missing for legacy string input', () => { + expect(() => toLucideIconData('AirVent', undefined as never)).toThrowError( + '[lucide]: iconNode is required when icon name is used', + ); + }); +}); diff --git a/packages/svelte/scripts/exportTemplate.mts b/packages/svelte/scripts/exportTemplate.mts index a01d30286..3de263f11 100644 --- a/packages/svelte/scripts/exportTemplate.mts +++ b/packages/svelte/scripts/exportTemplate.mts @@ -3,19 +3,20 @@ import { getHTMLBanner } from './license.mts'; import defineExportTemplate from '@lucide/build-icons/utils/defineExportTemplate'; export default defineExportTemplate( - async ({ iconName, children, getSvg, deprecated, deprecationReason }) => { + async ({ iconName, iconData, componentName, getSvg, deprecated, deprecationReason }) => { const svgContents = await getSvg(); const svgBase64 = base64SVG(svgContents); return `\ -${getHTMLBanner()} - + `; }, ); diff --git a/packages/svelte/src/Icon.svelte b/packages/svelte/src/Icon.svelte index cb05e43dc..71f0d2650 100644 --- a/packages/svelte/src/Icon.svelte +++ b/packages/svelte/src/Icon.svelte @@ -1,5 +1,5 @@ - - {#each iconNode as [tag, attrs]} + + {#each builtIconNode as [tag, attrs]} should render icon and match snapshot 1`] = ` should add a class to the element 1`] = ` should adjust the size, stroke color and should not scale the strokeWidth when ab stroke-width="1" stroke-linecap="round" stroke-linejoin="round" + class="lucide lucide-smile lucide-icon" aria-hidden="true" - class="lucide-icon lucide lucide-smile" > should render an component 1`] = ` should render an component 1`] = ` exports[`Using lucide icon components > should render an icon slot 1`] = ` { - const svgContents = await getSvg(); - const svgBase64 = base64SVG(svgContents); +export default defineExportTemplate( + async ({ componentName, iconName, iconData, getSvg, deprecated, deprecationReason }) => { + const svgContents = await getSvg(); + const svgBase64 = base64SVG(svgContents); - return ` + return ` import createLucideIcon from '../createLucideIcon'; -import { IconNode } from '../types'; +import type { LucideIconData } from '../types'; -export const __iconNode: IconNode = ${JSON.stringify(children)} +export const __iconData: LucideIconData = ${JSON.stringify(iconData)} /** * @component @name ${componentName} @@ -29,8 +23,9 @@ export const __iconNode: IconNode = ${JSON.stringify(children)} * @returns {FunctionalComponent} Vue component * ${deprecated ? `@deprecated ${deprecationReason}` : ''} */ -const ${componentName} = createLucideIcon('${iconName}', __iconNode); +const ${componentName} = createLucideIcon(__iconData); export default ${componentName}; `; -}); + }, +); diff --git a/packages/vue/src/Icon.ts b/packages/vue/src/Icon.ts index 75d85aee4..5bdecdb5f 100644 --- a/packages/vue/src/Icon.ts +++ b/packages/vue/src/Icon.ts @@ -1,5 +1,5 @@ import { computed, type FunctionalComponent, h } from 'vue'; -import { isEmptyString, mergeClasses, toKebabCase, toPascalCase } from '@lucide/shared'; +import { buildLucideIconNode, isEmptyString, toKebabCase } from '@lucide/shared'; import defaultAttributes from './defaultAttributes'; import { IconNode, LucideProps } from './types'; import { useLucideProps } from './context'; @@ -7,11 +7,13 @@ import { useLucideProps } from './context'; interface IconProps { iconNode: IconNode; name: string; + aliases?: string[]; } const Icon: FunctionalComponent = ( { name, + aliases, iconNode, absoluteStrokeWidth, 'absolute-stroke-width': absoluteStrokeWidthKebabCase, @@ -54,25 +56,39 @@ const Icon: FunctionalComponent = ( return strokeWidthValue; }); - return h( - 'svg', + const normalizedName = computed(() => (name != null ? toKebabCase(name) : undefined)); + + const iconName = computed(() => (normalizedName.value ? `${normalizedName.value}-icon` : 'icon')); + + const [, svgAttributes, builtIconNode = []] = buildLucideIconNode( { - ...defaultAttributes, - ...props, - width: size ?? contextSize ?? defaultAttributes.width, - height: size ?? contextSize ?? defaultAttributes.height, - stroke: color ?? contextColor ?? defaultAttributes.stroke, - 'stroke-width': calculatedStrokeWidth.value, - class: mergeClasses( - 'lucide', - contextClass, - ...(name - ? [`lucide-${toKebabCase(toPascalCase(name))}-icon`, `lucide-${toKebabCase(name)}`] - : ['lucide-icon']), - ), + name: iconName.value, + aliases, + size: 24, + node: iconNode, + }, + { + size: size ?? contextSize ?? defaultAttributes.width, + color: color ?? contextColor ?? defaultAttributes.stroke, + strokeWidth: calculatedStrokeWidth.value, + className: contextClass, }, - [...iconNode.map((child) => h(...child)), ...(slots.default ? [slots.default()] : [])], ); + + const iconAttributes = { + ...svgAttributes, + ...props, + width: svgAttributes.width, + height: svgAttributes.height, + stroke: svgAttributes.stroke, + 'stroke-width': svgAttributes['stroke-width'], + class: svgAttributes.class, + }; + + return h('svg', iconAttributes, [ + ...builtIconNode.map((child) => h(...child)), + ...(slots.default ? [slots.default()] : []), + ]); }; export default Icon; diff --git a/packages/vue/src/createLucideIcon.ts b/packages/vue/src/createLucideIcon.ts index 85919b106..10d5247df 100644 --- a/packages/vue/src/createLucideIcon.ts +++ b/packages/vue/src/createLucideIcon.ts @@ -1,23 +1,23 @@ import { h } from 'vue'; -import { IconNode, LucideIcon } from './types'; +import { LucideIcon, LucideIconData } from './types'; import Icon from './Icon'; /** * Create a Lucide icon component - * @param {string} iconName - * @param {array} iconNode + * @param {object} iconData * @returns {FunctionalComponent} LucideIcon */ const createLucideIcon = - (iconName: string, iconNode: IconNode): LucideIcon => + (iconData: LucideIconData): LucideIcon => (props, { slots, attrs }) => h( Icon, { ...attrs, ...props, - iconNode, - name: iconName, + iconNode: iconData.node, + name: iconData.name, + aliases: iconData.aliases, }, slots.default ? { default: slots.default } : undefined, ); diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index a8b865db6..38d5f2f6d 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -8,4 +8,9 @@ export interface LucideProps extends Partial { } export type IconNode = [elementName: string, attrs: Record][]; +export interface LucideIconData { + name: string; + node: IconNode; + aliases?: string[]; +} export type LucideIcon = FunctionalComponent; diff --git a/packages/vue/tests/__snapshots__/Icon.spec.ts.snap b/packages/vue/tests/__snapshots__/Icon.spec.ts.snap index 4b742c143..9907eb584 100644 --- a/packages/vue/tests/__snapshots__/Icon.spec.ts.snap +++ b/packages/vue/tests/__snapshots__/Icon.spec.ts.snap @@ -2,7 +2,7 @@ exports[`Using Icon Component > should render icon and match snapshot 1`] = ` should render the icon with LucideProvider 1`] = ` should add a class to the element 1`] = ` should add a class to the element 1`] = exports[`Using lucide icon components > should add a style attribute to the element 1`] = ` should add a style attribute to the elem exports[`Using lucide icon components > should adjust the size, stroke color and stroke width 1`] = ` should adjust the size, stroke color and exports[`Using lucide icon components > should pass children to the icon slot 1`] = ` should pass children to the icon slot 1` exports[`Using lucide icon components > should render an component 1`] = ` { expect(IconComponent).toHaveAttribute( 'class', - 'lucide provider-class lucide-house-icon lucide-house icon-class', + 'lucide lucide-house-icon lucide-home provider-class icon-class', ); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ae7209cf..39ee05ff8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,6 +357,9 @@ importers: '@lucide/rollup-plugins': specifier: workspace:* version: link:../../tools/rollup-plugins + '@lucide/shared': + specifier: workspace:* + version: link:../shared '@rollup/plugin-replace': specifier: ^6.0.2 version: 6.0.3(rollup@4.59.0)