diff --git a/.gitignore b/.gitignore index cf6cb25aa..9003ed236 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ 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/shared/src/build/types.d.ts packages/**/src/dynamicIconImports.ts packages/**/DynamicIcon.d.ts packages/**/dynamicIconImports.js diff --git a/package.json b/package.json index 0c1a28710..107fc84ad 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "ajv-cli": "^5.0.0", - "cspell": "^10.0.1", + "cpy-cli": "^7.0.0", "dotenv": "^17.4.2", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", diff --git a/packages/angular/package.json b/packages/angular/package.json index 05688d51c..e87a534bf 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -17,15 +17,16 @@ "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": "cpy ../../packages/shared/src/build/{buildLucideIconNode.ts,defaultAttributes.ts,types.ts} ../../packages/shared/src/utils/mergeClasses.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", - "lint": "eslint 'src/**/*.{js,jsx,ts,tsx,html,css,scss}' --quiet --fix", + "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" }, "prettier": { diff --git a/packages/angular/src/lucide-config.ts b/packages/angular/src/lucide-config.ts index 8a4953127..3944638f1 100644 --- a/packages/angular/src/lucide-config.ts +++ b/packages/angular/src/lucide-config.ts @@ -19,6 +19,11 @@ export interface LucideConfig { * @default 2 */ strokeWidth: number; + /** + * Whether to use absolute stroke width. + * @deprecated Use `nonScalingStroke` instead. + */ + absoluteStrokeWidth: boolean; /** * Whether stroke width should be scaled to appear uniform regardless of icon size. * @default false @@ -31,7 +36,7 @@ export interface LucideConfig { * } * ``` */ - absoluteStrokeWidth: boolean; + nonScalingStroke: boolean; } /** @@ -42,6 +47,7 @@ export const lucideDefaultConfig: LucideConfig = { size: 24, strokeWidth: 2, absoluteStrokeWidth: false, + nonScalingStroke: false, }; /** diff --git a/packages/angular/src/lucide-hydration.spec.ts b/packages/angular/src/lucide-hydration.spec.ts index 8521fa8e0..9b4757c85 100644 --- a/packages/angular/src/lucide-hydration.spec.ts +++ b/packages/angular/src/lucide-hydration.spec.ts @@ -8,7 +8,7 @@ import { LucideBadgeAlert } from './icons/badge-alert'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector selector: 'lucide-static-hydration-root', - template: ``, + template: ``, imports: [LucideBadgeAlert], }) class StaticHydrationRootComponent {} diff --git a/packages/angular/src/lucide-icon-base.spec.ts b/packages/angular/src/lucide-icon-base.spec.ts index f90f91596..f5df57065 100644 --- a/packages/angular/src/lucide-icon-base.spec.ts +++ b/packages/angular/src/lucide-icon-base.spec.ts @@ -47,6 +47,7 @@ describe('LucideIconBase', () => { let size: WritableSignal; let strokeWidth: WritableSignal; let absoluteStrokeWidth: WritableSignal; + let nonScalingStroke: WritableSignal; const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr); const expectSvgClasses = (classes: string[], svg: SVGElement = fixture.nativeElement) => { for (const cssClass of classes) { @@ -63,6 +64,7 @@ describe('LucideIconBase', () => { inputBinding('size', size), inputBinding('strokeWidth', strokeWidth), inputBinding('absoluteStrokeWidth', absoluteStrokeWidth), + inputBinding('nonScalingStroke', nonScalingStroke), ], }); } @@ -72,6 +74,7 @@ describe('LucideIconBase', () => { size = signal(undefined); strokeWidth = signal(undefined); absoluteStrokeWidth = signal(undefined); + nonScalingStroke = signal(undefined); fixture = createComponent(); component = fixture.componentInstance; }); @@ -135,6 +138,11 @@ describe('LucideIconBase', () => { expect(getSvgAttribute('width')).toBe('18'); expect(getSvgAttribute('height')).toBe('18'); }); + it('should set viewBox', () => { + size.set('18'); + fixture.detectChanges(); + expect(getSvgAttribute('viewBox')).toBe('0 0 24 24'); + }); }); describe('strokeWidth', () => { @@ -155,22 +163,38 @@ describe('LucideIconBase', () => { }); describe('absoluteStrokeWidth', () => { + it('should adjust stroke width', () => { + strokeWidth.set(2); + size.set(32); + absoluteStrokeWidth.set(true); + fixture.detectChanges(); + expect(getSvgAttribute('stroke-width')).toBe('1.5'); + }); + it('should not set vector-effect on children', () => { + absoluteStrokeWidth.set(true); + for (const child of fixture.nativeElement.children) { + expect(child.getAttribute('vector-effect')).toBeNull(); + } + }); + }); + + describe('nonScalingStroke', () => { it('should not adjust stroke width', () => { strokeWidth.set(2); size.set(12); - absoluteStrokeWidth.set(true); + nonScalingStroke.set(false); fixture.detectChanges(); expect(getSvgAttribute('stroke-width')).toBe('2'); }); it('should not set vector-effect on children', () => { - absoluteStrokeWidth.set(false); + nonScalingStroke.set(false); fixture.detectChanges(); for (const child of getRenderedChildren()) { expect(child.getAttribute('vector-effect')).toBeNull(); } }); it('should set vector-effect on children', () => { - absoluteStrokeWidth.set(true); + nonScalingStroke.set(true); fixture.detectChanges(); for (const child of getRenderedChildren()) { expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke'); @@ -207,6 +231,7 @@ describe('LucideIconBase', () => { color: 'red', strokeWidth: 1, size: 12, + nonScalingStroke: true, absoluteStrokeWidth: true, }), ], @@ -229,12 +254,12 @@ describe('LucideIconBase', () => { describe('strokeWidth', () => { it('should use stroke width from config', () => { fixture.detectChanges(); - expect(getSvgAttribute('stroke-width')).toBe('1'); + expect(getSvgAttribute('stroke-width')).toBe('2'); }); it('should use override stroke width from config', () => { strokeWidth.set(3); fixture.detectChanges(); - expect(getSvgAttribute('stroke-width')).toBe('3'); + expect(getSvgAttribute('stroke-width')).toBe('6'); }); }); describe('size', () => { @@ -253,14 +278,23 @@ describe('LucideIconBase', () => { describe('absoluteStrokeWidth', () => { it('should use absoluteStrokeWidth from config', () => { fixture.detectChanges(); - for (const child of getRenderedChildren()) { - expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke'); - } + expect(getSvgAttribute('stroke-width')).toBe('2'); }); it('should override absoluteStrokeWidth', () => { absoluteStrokeWidth.set(false); fixture.detectChanges(); - for (const child of getRenderedChildren()) { + expect(getSvgAttribute('stroke-width')).toBe('1'); + }); + }); + describe('nonScalingStroke', () => { + it('should use nonScalingStroke from config', () => { + for (const child of fixture.nativeElement.children) { + expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke'); + } + }); + it('should override nonScalingStroke', () => { + nonScalingStroke.set(false); + for (const child of fixture.nativeElement.children) { expect(child.getAttribute('vector-effect')).toBeNull(); } }); diff --git a/packages/angular/src/lucide-icon-base.ts b/packages/angular/src/lucide-icon-base.ts index 5708baab6..624307076 100644 --- a/packages/angular/src/lucide-icon-base.ts +++ b/packages/angular/src/lucide-icon-base.ts @@ -1,8 +1,9 @@ import { Component, computed, inject, input, Signal } from '@angular/core'; import { LUCIDE_CONFIG } from './lucide-config'; -import { LucideIconData, Nullable } from './types'; +import { LucideIconData, LucideIconNode, Nullable } from './types'; import defaultAttributes from './default-attributes'; import { lucideIconTemplate } from './lucide-icon-template'; +import buildLucideIconNode from './utils/buildLucideIconNode'; /** * @internal @@ -12,38 +13,17 @@ import { lucideIconTemplate } from './lucide-icon-template'; template: lucideIconTemplate, host: { ...defaultAttributes, - class: 'lucide', - '[class]': 'iconClasses()', - '[attr.width]': 'size()', - '[attr.height]': 'size()', - '[attr.stroke]': 'color()', - '[attr.stroke-width]': 'strokeWidth()', - '[attr.aria-hidden]': '!title()', + '[attr.class]': 'computedClass()', + '[attr.width]': 'computedWidth()', + '[attr.height]': 'computedHeight()', + '[attr.viewBox]': 'computedViewbox()', + '[attr.stroke]': 'computedStroke()', + '[attr.stroke-width]': 'computedStrokeWidth()', + '[attr.aria-hidden]': 'computedAriaHidden()', }, }) export abstract class LucideIconBase { protected abstract readonly icon: Signal>; - protected readonly iconNodes = computed(() => { - const node = this.icon()?.node ?? []; - if (!this.absoluteStrokeWidth()) { - return node; - } - return node.map(([name, attrs]) => [ - name, - { - 'vector-effect': 'non-scaling-stroke', - ...attrs, - }, - ]); - }); - protected readonly iconClasses = computed(() => { - const icon = this.icon(); - if (!icon) { - return ''; - } - const { name, aliases = [] } = icon; - return [name, ...aliases].map((item) => `lucide-${item}`).join(' '); - }); protected readonly iconConfig = inject(LUCIDE_CONFIG); /** * An optional accessible label for the icon. @@ -58,6 +38,10 @@ export abstract class LucideIconBase { * - If your icon is functional (e.g. used in place of a label), feel free to use this property. */ readonly title = input>(); + /** + * Optional CSS classes for the icon. + */ + readonly class = input>(); /** * Width and height. * @default 24 @@ -65,6 +49,16 @@ export abstract class LucideIconBase { readonly size = input(this.iconConfig.size, { transform: (value: Nullable) => value ?? this.iconConfig.size, }); + /** + * Icon width. + * @default 24 + */ + readonly width = input(null); + /** + * Icon height. + * @default 24 + */ + readonly height = input(null); /** * Stroke color. * @default currentColor @@ -80,9 +74,95 @@ export abstract class LucideIconBase { transform: (value: Nullable) => value ?? this.iconConfig.strokeWidth, }); /** - * If set to true, it adds [`vector-effect="non-scaling-stroke"`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/vector-effect) to child elements. + * Whether to use absolute stroke width. */ readonly absoluteStrokeWidth = input(this.iconConfig.absoluteStrokeWidth, { transform: (value: Nullable) => value ?? this.iconConfig.absoluteStrokeWidth, }); + /** + * If set to true, it adds [`vector-effect="non-scaling-stroke"`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/vector-effect) to child elements. + */ + readonly nonScalingStroke = input(this.iconConfig.nonScalingStroke, { + transform: (value: Nullable) => value ?? this.iconConfig.nonScalingStroke, + }); + + /** + * @internal + */ + protected readonly computedIcon = computed(() => { + const icon = this.icon(); + return icon + ? buildLucideIconNode(icon, { + width: this.width() ?? this.size(), + height: this.height() ?? this.size(), + color: this.color(), + strokeWidth: this.strokeWidth(), + absoluteStrokeWidth: this.absoluteStrokeWidth(), + nonScalingStroke: this.nonScalingStroke(), + className: this.class() ?? undefined, + hasA11yProp: this.title() != null, + }) + : null; + }); + + /** + * @internal + */ + protected readonly computedAttributes = computed( + () => this.computedIcon()?.[1], + ); + + /** + * @internal + */ + protected readonly computedIconNode = computed(() => this.computedIcon()?.[2] ?? []); + + /** + * @internal + */ + protected readonly computedWidth = computed( + () => this.computedAttributes()?.['width'] ?? this.iconConfig.size, + ); + + /** + * @internal + */ + protected readonly computedHeight = computed( + () => this.computedAttributes()?.['height'] ?? this.iconConfig.size, + ); + + /** + * @internal + */ + protected readonly computedStroke = computed( + () => this.computedAttributes()?.['stroke'] ?? this.iconConfig.color, + ); + + /** + * @internal + */ + protected readonly computedStrokeWidth = computed( + () => this.computedAttributes()?.['stroke-width'] ?? this.iconConfig.strokeWidth, + ); + + /** + * @internal + */ + protected readonly computedAriaHidden = computed( + () => this.computedAttributes()?.['aria-hidden'] === 'true', + ); + + /** + * @internal + */ + protected readonly computedViewbox = computed( + () => this.computedAttributes()?.['viewBox'] ?? '0 0 24 24', + ); + + /** + * @internal + */ + protected readonly computedClass = computed( + () => this.computedAttributes()?.['class'] ?? 'lucide', + ); } diff --git a/packages/angular/src/lucide-icon-template.ts b/packages/angular/src/lucide-icon-template.ts index ec95dc55f..5a6cde461 100644 --- a/packages/angular/src/lucide-icon-template.ts +++ b/packages/angular/src/lucide-icon-template.ts @@ -6,7 +6,7 @@ export const lucideIconTemplate = `@if (title(); as titleValue) { {{ titleValue }} } -@for (child of iconNodes(); track child[1]['key'] ?? $index) { +@for (child of computedIconNode(); track child[1]['key'] ?? $index) { @let attrs = child[1]; @switch (child[0]) { @case ('path') { diff --git a/packages/angular/src/types.ts b/packages/angular/src/types.ts index bc75de19e..c234ab405 100644 --- a/packages/angular/src/types.ts +++ b/packages/angular/src/types.ts @@ -1,18 +1,16 @@ import { Signal, Type } from '@angular/core'; +import { + LucideIconNode as SharedLucideIconNode, + LucideIconData as SharedLucideIconData, +} from './utils/types'; -type HtmlAttributes = { [key: string]: string | number }; -export type LucideIconNode = readonly [string, HtmlAttributes]; +export type LucideIconNode = SharedLucideIconNode; export type LucideIcons = { [key: string]: LucideIconData }; /** * A Lucide icon object that fully describes an icon to be displayed. */ -export type LucideIconData = { - name: string; - size?: number; - node: LucideIconNode[]; - aliases?: string[]; -}; +export type LucideIconData = SharedLucideIconData & { name: string }; /** * Input signal map of Lucide icon components. @@ -22,7 +20,11 @@ interface LucideIconProps { size: Signal>; color: Signal>; strokeWidth: Signal>; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth: Signal>; + nonScalingStroke: Signal>; } /** diff --git a/packages/astro/package.json b/packages/astro/package.json index f55794442..24bed5562 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -43,7 +43,7 @@ "build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --renderUniqueKey --withAliases --aliasesFileExtension=.ts --iconFileExtension=.ts --exportFileName=index.ts --pretty=false", "clean": "rm -rf dist && rm -rf stats && rm -rf ./src/icons/*.ts", "copy:license": "cp ../../LICENSE ./LICENSE", - "copy:utils": "mkdir -p ./src/utils && for f in hasA11yProp toKebabCase mergeClasses; do cp -f ../../packages/shared/src/utils/$f.ts ./src/utils/; done", + "copy:utils": "cpy ../../packages/shared/src/build/{buildLucideIconNode.ts,defaultAttributes.ts,types.ts} ../../packages/shared/src/utils/{hasA11yProp.ts,toKebabCase.ts,mergeClasses.ts} ./src/utils/", "test": "pnpm copy:utils && pnpm build:icons && vitest run", "test:watch": "pnpm build:icons && vitest run --watch" }, 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..7d54bedfa 100644 --- a/packages/astro/src/Icon.astro +++ b/packages/astro/src/Icon.astro @@ -1,32 +1,44 @@ --- -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, + width, + height, 'stroke-width': strokeWidth = 2, absoluteStrokeWidth = false, - iconNode = [], + nonScalingStroke = false, + iconNode, + icon = { + node: iconNode, + aliases: [], + size: 24, + }, class: className, ...rest } = Astro.props; + +const [, builtIconAttributes, builtIconNode = []] = buildLucideIconNode(icon, { + color, + width: width ?? size, + height: height ?? size, + strokeWidth, + absoluteStrokeWidth, + nonScalingStroke, + className, + hasA11yProp: hasA11yProp(rest), +}); + +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 36c1c99e7..7d5b2a594 100644 --- a/packages/astro/src/createLucideIcon.ts +++ b/packages/astro/src/createLucideIcon.ts @@ -1,11 +1,43 @@ import type { AstroComponentFactory } from 'astro/runtime/server/render/astro/factory.js'; -import type { IconNode } from './types'; +import type { LucideIconData, LucideIconNode } 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 => { +/** + * Creates a Lucide icon component from icon data. + * + * @param icon Icon data object. + * @returns Lucide icon component. + */ +function createLucideIcon(icon: LucideIconData): AstroComponentFactory; + +/** + * 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[], +): AstroComponentFactory; + +function createLucideIcon( + iconOrIconName: string | LucideIconData, + iconNode: LucideIconNode[] = [], + aliases: string[] = [], +): AstroComponentFactory { + const icon = + typeof iconOrIconName === 'string' + ? { + name: iconOrIconName, + node: iconNode, + aliases, + } + : iconOrIconName; const Component = createComponent( ($$result, $$props: Record, $$slots) => { const { class: className, ...restProps } = $$props; @@ -14,11 +46,8 @@ export default (iconName: string, iconNode: IconNode): AstroComponentFactory => 'Icon', Icon, { - class: mergeClasses( - Boolean(iconName) && `lucide-${toKebabCase(iconName)}`, - Boolean(className) && className, - ), - iconNode, + class: className, + icon, ...restProps, }, { default: () => render`${renderSlot($$result, $$slots['default'])}` }, @@ -28,4 +57,6 @@ export default (iconName: string, iconNode: IconNode): AstroComponentFactory => 'none', ); return Component; -}; +} + +export default createLucideIcon; diff --git a/packages/astro/src/defaultAttributes.ts b/packages/astro/src/defaultAttributes.ts deleted file mode 100644 index 5b1fddd06..000000000 --- a/packages/astro/src/defaultAttributes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { SVGAttributes } from './types'; - -const defaultAttributes: SVGAttributes = { - 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', -}; - -export default defaultAttributes; diff --git a/packages/astro/src/lucide-astro.ts b/packages/astro/src/lucide-astro.ts index adc0c1003..19d041177 100644 --- a/packages/astro/src/lucide-astro.ts +++ b/packages/astro/src/lucide-astro.ts @@ -3,6 +3,6 @@ export * as icons from './icons/index'; export * from './aliases'; export * from './types'; -export { default as defaultAttributes } from './defaultAttributes'; +export { default as defaultAttributes } from './utils/defaultAttributes'; export { default as createLucideIcon } from './createLucideIcon'; export { default as Icon } from './Icon.astro'; diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index 451e3d181..d23968290 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -1,23 +1,47 @@ import type { HTMLAttributes } from 'astro/types'; +import type { + LucideIconData as SharedLucideIconData, + LucideIconNode as SharedLucideIconNode, +} from './utils/types'; // Type that the Astro language server needs to infer component props in Astro files -export type AstroComponent = (_props: IconProps) => unknown; +export type AstroComponent = (_props: LucideProps) => any; -export interface IconProps extends SVGAttributes { +export type LucideProps = SVGAttributes & { color?: string; size?: number | string; 'stroke-width'?: number | string; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + nonScalingStroke?: boolean; class?: string; - iconNode?: IconNode; title?: string; -} +}; + +export type IconProps = LucideProps & + ( + | { + icon: LucideIconData; + iconNode?: never; + } + | { + icon?: never; + iconNode: LucideIconNode[]; + } + ); export type SVGAttributes = HTMLAttributes<'svg'>; -export type IconNode = IconNodeChild[]; +export type LucideIconNode = SharedLucideIconNode; -type IconNodeChild = [elementName: SVGElements, attrs: SVGAttributes]; +export type LucideIconData = SharedLucideIconData; + +/** + * @deprecated Use LucideIconNode instead. + */ +export type IconNode = LucideIconNode[]; // All possible svg elements according to the Astro definitions type SVGElements = diff --git a/packages/astro/tests/__snapshots__/Icon.spec.ts.snap b/packages/astro/tests/__snapshots__/Icon.spec.ts.snap index a550ccb86..f3bc1ec4d 100644 --- a/packages/astro/tests/__snapshots__/Icon.spec.ts.snap +++ b/packages/astro/tests/__snapshots__/Icon.spec.ts.snap @@ -10,8 +10,8 @@ exports[`Using Icon Component > should render icon and match snapshot 1`] = ` stroke-width="1" stroke-linecap="round" stroke-linejoin="round" - aria-hidden="true" class="lucide" + aria-hidden="true" > diff --git a/packages/astro/tests/__snapshots__/createLucideIcon.spec.ts.snap b/packages/astro/tests/__snapshots__/createLucideIcon.spec.ts.snap index 13a285420..e53acc2bb 100644 --- a/packages/astro/tests/__snapshots__/createLucideIcon.spec.ts.snap +++ b/packages/astro/tests/__snapshots__/createLucideIcon.spec.ts.snap @@ -10,8 +10,8 @@ exports[`Using createLucideIcon > should create a component from an iconNode 1`] stroke-width="2" stroke-linecap="round" stroke-linejoin="round" - aria-hidden="true" class="lucide lucide-air-vent" + aria-hidden="true" > diff --git a/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap b/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap index 35f95548d..67aaead72 100644 --- a/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap +++ b/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap @@ -10,9 +10,9 @@ exports[`Using lucide icon components > should add a non-default attribute to th stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + class="lucide lucide-smile" aria-hidden="true" style="position: absolute" - class="lucide lucide-face-slightly-smiling" > @@ -38,8 +38,8 @@ exports[`Using lucide icon components > should adjust the size, stroke color and stroke-width="4" stroke-linecap="round" stroke-linejoin="round" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" aria-hidden="true" - class="lucide lucide-grid-3x3" > should apply all classes to the element stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + class="lucide lucide-droplet lucide-drop my-icon" aria-hidden="true" - class="lucide lucide-droplet my-icon" > `; -exports[`Using lucide icon components > should not scale the strokeWidth when absoluteStrokeWidth is set 1`] = ` +exports[`Using lucide icon components > should apply vector-effect when nonScalingStroke is set 1`] = ` `; @@ -118,8 +127,8 @@ exports[`Using lucide icon components > should pass children to the icon slot 1` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + class="lucide lucide-smile" aria-hidden="true" - class="lucide lucide-face-slightly-smiling" > @@ -148,8 +157,8 @@ exports[`Using lucide icon components > should render a component 1`] = ` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" aria-hidden="true" - class="lucide lucide-grid-3x3" > should render the icon with default attr stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" + aria-hidden="true" +> + + + + + + + + + + + +`; + +exports[`Using lucide icon components > should scale the strokeWidth when absoluteStrokeWidth is set 1`] = ` +
`; -exports[`Using lucide icon components > should not scale the strokeWidth when absoluteStrokeWidth is set 1`] = ` +exports[`Using lucide icon components > should apply vector-effect when nonScalingStroke is set 1`] = ` `; @@ -132,7 +145,7 @@ exports[`Using lucide icon components > should render an component 1`] = `
should render an icon slot 1`] = `
`; + +exports[`Using lucide icon components > should scale the strokeWidth when absoluteStrokeWidth is set 1`] = ` + +`; diff --git a/packages/svelte/tests/lucide-svelte.spec.ts b/packages/svelte/tests/lucide-svelte.spec.ts index da19e096b..4713a05bf 100644 --- a/packages/svelte/tests/lucide-svelte.spec.ts +++ b/packages/svelte/tests/lucide-svelte.spec.ts @@ -66,7 +66,7 @@ describe('Using lucide icon components', () => { expect(PenIconRenderedHTML).toBe(Edit2Container.innerHTML); }); - it('should not scale the strokeWidth when absoluteStrokeWidth is set', () => { + it('should scale the strokeWidth when absoluteStrokeWidth is set', () => { const { container } = render(Smile, { color: 'red', size: 48, @@ -83,6 +83,24 @@ describe('Using lucide icon components', () => { expect(container.innerHTML).toMatchSnapshot(); }); + it('should apply vector-effect when nonScalingStroke is set', () => { + const { container } = render(Smile, { + color: 'red', + size: 48, + nonScalingStroke: true, + }); + + const IconComponent = container.firstElementChild; + + expect(IconComponent).toHaveAttribute('width', '48'); + expect(IconComponent).toHaveAttribute('height', '48'); + expect(IconComponent).toHaveAttribute('stroke', 'red'); + expect(IconComponent).toHaveAttribute('stroke-width', '2'); + expect(IconComponent?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + + expect(container.innerHTML).toMatchSnapshot(); + }); + it('should use context values from the global set properties', () => { const { container } = render(ContextWrapper); diff --git a/packages/svelte/tests/setupVitest.ts b/packages/svelte/tests/setupVitest.ts index 03a0244af..01bf442b9 100644 --- a/packages/svelte/tests/setupVitest.ts +++ b/packages/svelte/tests/setupVitest.ts @@ -1,5 +1,6 @@ import { expect } from 'vitest'; -import '@testing-library/jest-dom/vitest'; +import * as matchers from '@testing-library/jest-dom/matchers'; import htmlSerializer from 'jest-serializer-html'; +expect.extend(matchers); expect.addSnapshotSerializer(htmlSerializer); diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json index 3ef7dda35..58280e442 100644 --- a/packages/svelte/tsconfig.json +++ b/packages/svelte/tsconfig.json @@ -6,6 +6,11 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@lucide/shared": ["../shared/src/index.ts"], + "@lucide/shared/types": ["../shared/src/build/types.ts"], + }, "allowJs": true, "checkJs": true, "isolatedModules": true, diff --git a/packages/vue/rollup.config.mjs b/packages/vue/rollup.config.mjs index 94bba42f8..b69ab424a 100644 --- a/packages/vue/rollup.config.mjs +++ b/packages/vue/rollup.config.mjs @@ -2,6 +2,11 @@ import plugins from '@lucide/rollup-plugins'; import pkg from './package.json' with { type: 'json' }; import dts from 'rollup-plugin-dts'; +const dtsOptions = { + includeExternal: ['@lucide/shared/types'], + tsconfig: './tsconfig.json', +}; + const packageName = '@lucide/vue'; const outputFileName = 'lucide-vue'; const outputDir = 'dist'; @@ -58,13 +63,7 @@ export default [ format: 'es', }, ], - plugins: [ - dts({ - compilerOptions: { - preserveSymlinks: false, - }, - }), - ], + plugins: [dts(dtsOptions)], }, { input: `src/${outputFileName}.suffixed.ts`, @@ -74,13 +73,7 @@ export default [ format: 'es', }, ], - plugins: [ - dts({ - compilerOptions: { - preserveSymlinks: false, - }, - }), - ], + plugins: [dts(dtsOptions)], }, { input: `src/${outputFileName}.prefixed.ts`, @@ -90,13 +83,7 @@ export default [ format: 'es', }, ], - plugins: [ - dts({ - compilerOptions: { - preserveSymlinks: false, - }, - }), - ], + plugins: [dts(dtsOptions)], }, ...configs, ]; diff --git a/packages/vue/scripts/exportTemplate.mts b/packages/vue/scripts/exportTemplate.mts index d42df2a61..6f85690b8 100644 --- a/packages/vue/scripts/exportTemplate.mts +++ b/packages/vue/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 {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 8d4254460..99c85a93a 100644 --- a/packages/vue/src/Icon.ts +++ b/packages/vue/src/Icon.ts @@ -1,25 +1,56 @@ -import { computed, type FunctionalComponent, h } from 'vue'; -import { isEmptyString, mergeClasses, toKebabCase, toPascalCase } from '@lucide/shared'; -import defaultAttributes from './defaultAttributes'; -import { IconNode, LucideProps } from './types'; +import { type FunctionalComponent, h } from 'vue'; +import { + buildLucideIconNode, + hasA11yProp, + isEmptyString, + mergeClasses, + toKebabCase, +} from '@lucide/shared'; +import { LucideIconNode, LucideIconData, LucideProps } from './types'; import { useLucideProps } from './context'; -type IconProps = { name: string } & ( - | { iconNode: IconNode; 'icon-node'?: never } - | { 'icon-node': IconNode; iconNode?: never } -); +type IconProps = + | { + icon: LucideIconData; + iconNode?: never; + 'icon-node'?: never; + name?: never; + } + | { + icon?: never; + iconNode: LucideIconNode[]; + 'icon-node'?: never; + name: string; + } + | { + icon?: never; + iconNode?: never; + 'icon-node': LucideIconNode[]; + name: string; + }; const Icon: FunctionalComponent = ( { name, iconNode, 'icon-node': iconNodeKebabCase, + icon = { + name: toKebabCase(name), + node: iconNode ?? iconNodeKebabCase, + size: 24, + aliases: [], + }, absoluteStrokeWidth, 'absolute-stroke-width': absoluteStrokeWidthKebabCase, + nonScalingStroke, + 'non-scaling-stroke': nonScalingStrokeKebabCase, strokeWidth, 'stroke-width': strokeWidthKebabCase, size, + width = size, + height = size, color, + class: classes = '', ...props }, { slots }, @@ -29,54 +60,44 @@ const Icon: FunctionalComponent = ( color: contextColor, strokeWidth: contextStrokeWidth = 2, absoluteStrokeWidth: contextAbsoluteStrokeWidth = false, + nonScalingStroke: contextNonScalingStroke = false, class: contextClass = '', } = useLucideProps(); - const calculatedStrokeWidth = computed(() => { - const isAbsoluteStrokeWidth = - isEmptyString(absoluteStrokeWidth) || - isEmptyString(absoluteStrokeWidthKebabCase) || - absoluteStrokeWidth === true || - absoluteStrokeWidthKebabCase === true || - contextAbsoluteStrokeWidth === true; + const isAbsoluteStrokeWidth = + isEmptyString(absoluteStrokeWidth) || + isEmptyString(absoluteStrokeWidthKebabCase) || + absoluteStrokeWidth === true || + absoluteStrokeWidthKebabCase === true || + contextAbsoluteStrokeWidth === true; - const strokeWidthValue = - strokeWidth || - strokeWidthKebabCase || - contextStrokeWidth || - defaultAttributes['stroke-width']; + const isNonScalingStroke = + isEmptyString(nonScalingStroke) || + isEmptyString(nonScalingStrokeKebabCase) || + nonScalingStroke === true || + nonScalingStrokeKebabCase === true || + contextNonScalingStroke === true; - if (isAbsoluteStrokeWidth) { - return ( - (Number(strokeWidthValue) * 24) / Number(size ?? contextSize ?? defaultAttributes.width) - ); - } + const { class: propsClass, ...restProps } = props; - return strokeWidthValue; + const defaultSlot = slots.default?.(); + + const [, svgAttributes, builtIconNode = []] = buildLucideIconNode(icon, { + color: color ?? contextColor, + width: width ?? size ?? contextSize, + height: height ?? size ?? contextSize, + strokeWidth: strokeWidth ?? strokeWidthKebabCase ?? contextStrokeWidth, + absoluteStrokeWidth: isAbsoluteStrokeWidth, + nonScalingStroke: isNonScalingStroke, + className: mergeClasses(contextClass, propsClass), + hasA11yProp: (defaultSlot != null && defaultSlot.length > 0) || hasA11yProp(restProps), + attributes: restProps, }); - return h( - 'svg', - { - ...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']), - ), - }, - [ - ...(iconNode ?? iconNodeKebabCase ?? []).map((child) => h(...child)), - ...(slots.default ? [slots.default()] : []), - ], - ); + return h('svg', svgAttributes, [ + ...builtIconNode.map((child) => h(...child)), + ...(defaultSlot ?? []), + ]); }; export default Icon; diff --git a/packages/vue/src/context.ts b/packages/vue/src/context.ts index 7af325ec7..0435ed55c 100644 --- a/packages/vue/src/context.ts +++ b/packages/vue/src/context.ts @@ -6,7 +6,11 @@ interface LucideIconsContext { size?: number; color?: string; strokeWidth?: number; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + nonScalingStroke?: boolean; class?: string; } diff --git a/packages/vue/src/createLucideIcon.ts b/packages/vue/src/createLucideIcon.ts index 85919b106..5981b8153 100644 --- a/packages/vue/src/createLucideIcon.ts +++ b/packages/vue/src/createLucideIcon.ts @@ -1,25 +1,44 @@ import { h } from 'vue'; -import { IconNode, LucideIcon } from './types'; +import { LucideIcon, LucideIconData, LucideIconNode } from './types'; import Icon from './Icon'; /** - * Create a Lucide icon component - * @param {string} iconName - * @param {array} iconNode - * @returns {FunctionalComponent} LucideIcon + * Creates a Lucide icon component from icon data. + * + * @param icon Icon data object. + * @returns Lucide icon component. */ -const createLucideIcon = - (iconName: string, iconNode: IconNode): LucideIcon => - (props, { slots, attrs }) => +function createLucideIcon(icon: LucideIconData): LucideIcon; + +/** + * Creates a Lucide icon component from the legacy icon arguments. + * + * @param iconName Icon name. + * @param iconNode Icon node. + * @returns Lucide icon component. + */ +function createLucideIcon(iconName: string, iconNode: LucideIconNode[]): LucideIcon; + +function createLucideIcon( + iconDataOrName: LucideIconData | string, + iconNode: LucideIconNode[] = [], +): LucideIcon { + const icon: LucideIconData = + typeof iconDataOrName === 'string' + ? { + name: iconDataOrName, + node: iconNode, + } + : iconDataOrName; + return (props, { slots }) => h( Icon, { - ...attrs, ...props, - iconNode, - name: iconName, + icon, }, slots.default ? { default: slots.default } : undefined, ); +} export default createLucideIcon; diff --git a/packages/vue/src/defaultAttributes.ts b/packages/vue/src/defaultAttributes.ts index 8ae7d46ea..cc4036fbd 100644 --- a/packages/vue/src/defaultAttributes.ts +++ b/packages/vue/src/defaultAttributes.ts @@ -1,11 +1 @@ -export default { - 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', -}; +export { defaultAttributes as default } from '@lucide/shared'; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index a8b865db6..f292be26a 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -1,11 +1,31 @@ import type { FunctionalComponent, SVGAttributes } from 'vue'; +import type { + LucideIconData as SharedLucideIconData, + LucideIconNode as SharedLucideIconNode, +} from '@lucide/shared/types'; export interface LucideProps extends Partial { size?: 24 | number; strokeWidth?: number | string; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + /** + * @deprecated Use `nonScalingStroke` instead. + */ 'absolute-stroke-width'?: boolean; + nonScalingStroke?: boolean; + 'non-scaling-stroke'?: boolean; } -export type IconNode = [elementName: string, attrs: Record][]; +export type LucideIconNode = SharedLucideIconNode; + +export type LucideIconData = SharedLucideIconData; + +/** + * @deprecated Use LucideIconNode instead. + */ +export type IconNode = LucideIconNode[]; + export type LucideIcon = FunctionalComponent; diff --git a/packages/vue/tests/Icon.spec.ts b/packages/vue/tests/Icon.spec.ts index d8ec174a8..525547653 100644 --- a/packages/vue/tests/Icon.spec.ts +++ b/packages/vue/tests/Icon.spec.ts @@ -5,7 +5,9 @@ import { airVent } from './testIconNodes'; import { Icon } from '../src/lucide-vue'; describe('Using Icon Component', () => { - it('should render icon based on a iconNode', async () => { + const airVentIcon = { name: 'air-vent', node: airVent, size: 24 }; + + it('should render icon based on an icon node', async () => { const { container } = render(Icon, { props: { iconNode: airVent, @@ -16,7 +18,7 @@ describe('Using Icon Component', () => { }, }); - expect(container.firstChild).toBeDefined(); + expect(container.firstChild).toMatchSnapshot(); }); it('should render when iconNode is passed as the kebab-case icon-node prop', async () => { @@ -34,8 +36,7 @@ describe('Using Icon Component', () => { it('should render icon and match snapshot', async () => { const { container } = render(Icon, { props: { - iconNode: airVent, - name: 'AirVent', + icon: airVentIcon, size: 48, color: 'red', absoluteStrokeWidth: true, diff --git a/packages/vue/tests/__snapshots__/Icon.spec.ts.snap b/packages/vue/tests/__snapshots__/Icon.spec.ts.snap index 4b742c143..9257657d8 100644 --- a/packages/vue/tests/__snapshots__/Icon.spec.ts.snap +++ b/packages/vue/tests/__snapshots__/Icon.spec.ts.snap @@ -2,7 +2,37 @@ exports[`Using Icon Component > should render icon and match snapshot 1`] = ` +`; + +exports[`Using Icon Component > should render icon based on an icon node 1`] = ` +
should pass children to the icon slot 1` cy="12" r="10" /> - + + + Hello World -
`; @@ -137,7 +153,8 @@ exports[`Using lucide icon components > should pass children to the icon slot 1` exports[`Using lucide icon components > should render an component 1`] = `