diff --git a/.gitignore b/.gitignore index 7aab235ad..fc8681b54 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ 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 7bb11484b..adf630030 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@vitest/coverage-v8": "4.0.12", "@vitest/ui": "4.0.12", "ajv-cli": "^5.0.0", + "cpy-cli": "^7.0.0", "dotenv": "^17.4.2", "eslint": "^8.57.1", "eslint-config-airbnb-base": "^15.0.0", diff --git a/packages/angular/package.json b/packages/angular/package.json index 7ba81a27e..91438770a 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -20,7 +20,7 @@ "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/", + "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", 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-icon-base.spec.ts b/packages/angular/src/lucide-icon-base.spec.ts index ff0154357..736847c44 100644 --- a/packages/angular/src/lucide-icon-base.spec.ts +++ b/packages/angular/src/lucide-icon-base.spec.ts @@ -41,6 +41,7 @@ describe('LucideIconBase', () => { let size: WritableSignal; let strokeWidth: WritableSignal; let absoluteStrokeWidth: WritableSignal; + let nonScalingStroke: WritableSignal; const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr); function createComponent() { return TestBed.createComponent(LucideCircleCheck, { @@ -51,6 +52,7 @@ describe('LucideIconBase', () => { inputBinding('size', size), inputBinding('strokeWidth', strokeWidth), inputBinding('absoluteStrokeWidth', absoluteStrokeWidth), + inputBinding('nonScalingStroke', nonScalingStroke), ], }); } @@ -60,6 +62,7 @@ describe('LucideIconBase', () => { size = signal(undefined); strokeWidth = signal(undefined); absoluteStrokeWidth = signal(undefined); + nonScalingStroke = signal(undefined); fixture = createComponent(); component = fixture.componentInstance; }); @@ -113,6 +116,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', () => { @@ -133,21 +141,37 @@ 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); for (const child of fixture.nativeElement.children) { expect(child.getAttribute('vector-effect')).toBeNull(); } }); it('should set vector-effect on children', () => { - absoluteStrokeWidth.set(true); + nonScalingStroke.set(true); for (const child of fixture.nativeElement.children) { expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke'); } @@ -205,12 +229,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', () => { @@ -239,5 +263,18 @@ describe('LucideIconBase', () => { } }); }); + 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 59fdb9667..156091cb0 100644 --- a/packages/angular/src/lucide-icon-base.ts +++ b/packages/angular/src/lucide-icon-base.ts @@ -1,5 +1,6 @@ import { Component, + computed, effect, ElementRef, inject, @@ -9,7 +10,7 @@ import { viewChild, } 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'; @@ -23,12 +24,13 @@ import buildLucideIconNode from './utils/buildLucideIconNode'; template: lucideIconTemplate, host: { ...defaultAttributes, - class: 'lucide', - '[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 { @@ -57,6 +59,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 @@ -72,36 +84,105 @@ 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(), + 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', + ); constructor() { effect((onCleanup) => { - const icon = this.icon(); - if (icon) { - const absoluteStrokeWidth = this.absoluteStrokeWidth(); - 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 iconNode = this.computedIconNode(); + const iconAttributes = this.computedIconNode(); + if (iconNode && iconAttributes) { const contentRef = this.contentRef(); const refChild = contentRef.nativeElement; - const elements = (iconNode ?? []).map(([name, attrs]) => { + const elements = iconNode.map(([name, attrs]) => { const element = this.renderer.createElement(name, 'http://www.w3.org/2000/svg'); Object.entries(attrs).forEach(([name, value]) => @@ -118,9 +199,6 @@ export abstract class LucideIconBase { elements.forEach((element) => this.renderer.removeChild(this.elRef.nativeElement, element), ); - for (const cssClass of classes) { - this.renderer.removeClass(this.elRef.nativeElement, cssClass); - } }); } }); 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 6f44dede1..76f1aa944 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", "version": "pnpm version --git-tag-version=false" diff --git a/packages/astro/src/Icon.astro b/packages/astro/src/Icon.astro index df27db114..b20d3b835 100644 --- a/packages/astro/src/Icon.astro +++ b/packages/astro/src/Icon.astro @@ -6,45 +6,39 @@ import { hasA11yProp } from './utils/hasA11yProp'; const { color = 'currentColor', size = 24, + width, + height, 'stroke-width': strokeWidth = 2, absoluteStrokeWidth = false, - name, - aliases, - iconNode = [], + nonScalingStroke = false, + iconNode, + icon = { + node: iconNode, + aliases: [], + size: 24, + }, class: className, ...rest } = Astro.props; -const calculatedStrokeWidth = - absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth; +const [, builtIconAttributes, builtIconNode] = buildLucideIconNode(icon, { + color, + width: width ?? size, + height: height ?? size, + strokeWidth, + absoluteStrokeWidth, + nonScalingStroke, + className, + hasA11yProp: hasA11yProp(rest), +}); -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, }; --- - + {builtIconNode.map(([Tag, attrs]) => )} diff --git a/packages/astro/src/createLucideIcon.ts b/packages/astro/src/createLucideIcon.ts index 22d12e942..e693daf7e 100644 --- a/packages/astro/src/createLucideIcon.ts +++ b/packages/astro/src/createLucideIcon.ts @@ -1,9 +1,43 @@ import type { AstroComponentFactory } from 'astro/runtime/server/render/astro/factory.js'; -import type { LucideIconData } from './types'; +import type { LucideIconData, LucideIconNode } from './types'; import { render, renderSlot, createComponent, renderComponent } from 'astro/compiler-runtime'; import Icon from './Icon.astro'; -export default (iconData: LucideIconData): 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; @@ -13,9 +47,7 @@ export default (iconData: LucideIconData): AstroComponentFactory => { Icon, { class: className, - name: iconData.name, - aliases: iconData.aliases, - iconNode: iconData.node, + icon, ...restProps, }, { default: () => render`${renderSlot($$result, $$slots['default'])}` }, @@ -25,4 +57,6 @@ export default (iconData: LucideIconData): 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 d7ce5f411..f8ad56c01 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -1,31 +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) => any; +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; - name?: string; - aliases?: 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; -export interface LucideIconData { - name: string; - node: IconNode; - aliases?: string[]; -} +export type LucideIconData = SharedLucideIconData; -type IconNodeChild = [elementName: SVGElements, attrs: SVGAttributes]; +/** + * @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 6f9ff2921..218463aea 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-smile" > should adjust the size, stroke color and stroke-width="4" stroke-linecap="round" stroke-linejoin="round" - aria-hidden="true" class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" + aria-hidden="true" > 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`] = ` `; @@ -126,8 +135,8 @@ exports[`Using lucide icon components > should pass children to the icon slot 1` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" - aria-hidden="true" class="lucide lucide-smile" + aria-hidden="true" > should render a component 1`] = ` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" - aria-hidden="true" class="lucide lucide-grid-3x3 lucide-grid lucide-grid-3-x-3" + aria-hidden="true" > should render the icon with default attr stroke-width="2" stroke-linecap="round" stroke-linejoin="round" - aria-hidden="true" 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`] = ` `; @@ -92,3 +101,34 @@ exports[`Using lucide icon components > should render an component 1`] = ` `; + +exports[`Using lucide icon components > should scale the strokeWidth when absoluteStrokeWidth is set 1`] = ` + +`; diff --git a/packages/lucide-preact/tests/context.spec.tsx b/packages/lucide-preact/tests/context.spec.tsx index 856b148fd..c2afcc312 100644 --- a/packages/lucide-preact/tests/context.spec.tsx +++ b/packages/lucide-preact/tests/context.spec.tsx @@ -51,6 +51,22 @@ describe('Using LucideProvider', () => { expect(IconComponent).toHaveAttribute('stroke-width', '1'); }); + it('should render the icon with LucideProvider and custom nonScalingStroke', () => { + const { container } = render( + + + , + ); + + const IconComponent = container.firstElementChild; + + expect(IconComponent?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + }); + it("should override the provider's global props when passing props to the icon", () => { const { container } = render( }; @@ -70,7 +70,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( { expect(container.innerHTML).toMatchSnapshot(); }); + it('should apply vector-effect when nonScalingStroke is set', () => { + const { container } = render( + , + ); + + const SVGElement = container.firstElementChild; + + expect(SVGElement).toHaveAttribute('stroke', 'red'); + expect(SVGElement).toHaveAttribute('width', '48'); + expect(SVGElement).toHaveAttribute('height', '48'); + expect(SVGElement).toHaveAttribute('stroke-width', '2'); + expect(SVGElement?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + + expect(container.innerHTML).toMatchSnapshot(); + }); + it('should apply all classes to the element', () => { + const iconData: LucideIconData = { + name: 'droplet', + size: 24, + node: [ + [ + 'path', + { + d: 'M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z', + key: 'abc123', + }, + ], + ], + aliases: ['drop'], + }; const testClass = 'my-class'; - const { container } = render(); + const Icon = createLucideIcon(iconData); + const { container } = render(); expect(container.firstChild).toHaveClass(testClass); expect(container.firstChild).toHaveClass('lucide'); expect(container.firstChild).toHaveClass('lucide-droplet'); + expect(container.firstChild).toHaveClass('lucide-drop'); }); }); diff --git a/packages/lucide-preact/tsconfig.json b/packages/lucide-preact/tsconfig.json index 61ae11e69..ff8a883fe 100644 --- a/packages/lucide-preact/tsconfig.json +++ b/packages/lucide-preact/tsconfig.json @@ -8,6 +8,11 @@ "moduleResolution": "node", "module": "ESNext", "target": "ESNext", + "baseUrl": ".", + "paths": { + "@lucide/shared": ["../shared/src/index.ts"], + "@lucide/shared/types": ["../shared/src/build/types.ts"], + }, "esModuleInterop": true, "isolatedModules": true, "lib": ["esnext", "dom"], diff --git a/packages/lucide-react-native/rollup.config.mjs b/packages/lucide-react-native/rollup.config.mjs index cc692ecab..8eba95708 100644 --- a/packages/lucide-react-native/rollup.config.mjs +++ b/packages/lucide-react-native/rollup.config.mjs @@ -2,6 +2,11 @@ import plugins from '@lucide/rollup-plugins'; import dts from 'rollup-plugin-dts'; import pkg from './package.json' with { type: 'json' }; +const dtsOptions = { + includeExternal: ['@lucide/shared/types'], + tsconfig: './tsconfig.json', +}; + const packageName = 'LucideReact'; const outputFileName = 'lucide-react-native'; const outputDir = 'dist'; @@ -61,7 +66,7 @@ export default [ format: 'es', }, ], - plugins: [dts()], + plugins: [dts(dtsOptions)], }, { input: inputs[1], @@ -71,7 +76,7 @@ export default [ format: 'es', }, ], - plugins: [dts()], + plugins: [dts(dtsOptions)], }, { input: `src/${outputFileName}.suffixed.ts`, @@ -81,7 +86,7 @@ export default [ format: 'es', }, ], - plugins: [dts()], + plugins: [dts(dtsOptions)], }, { input: `src/${outputFileName}.prefixed.ts`, @@ -91,7 +96,7 @@ export default [ format: 'es', }, ], - plugins: [dts()], + plugins: [dts(dtsOptions)], }, ...configs, ]; diff --git a/packages/lucide-react-native/src/Icon.ts b/packages/lucide-react-native/src/Icon.ts index d00627b44..0d49ccba9 100644 --- a/packages/lucide-react-native/src/Icon.ts +++ b/packages/lucide-react-native/src/Icon.ts @@ -1,15 +1,14 @@ 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 { LucideIconNode, LucideProps } from './types'; +import { buildLucideIconForReact, hasA11yProp, mergeClasses } from '@lucide/shared'; +import { childDefaultAttributes } from './defaultAttributes'; +import { LucideIconData, LucideIconNode, LucideProps } from './types'; import { useLucideContext } from './context'; -interface IconComponentProps extends LucideProps { - iconNode: LucideIconNode; +type IconComponentProps = LucideProps & { testID?: string; className?: string; -} +} & ({ icon: LucideIconData; iconNode?: never } | { icon?: never; iconNode: LucideIconNode[] }); const toNativeSvgAttrName = (attributeName: string) => { if (attributeName === 'class') { @@ -44,8 +43,8 @@ const toNativeSvgAttributes = (attributes: Record) => { * @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 {LucideIconNode} props.children - The children of the icon - * @param {LucideIconNode} 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 */ @@ -54,10 +53,17 @@ const Icon = forwardRef( { color, size, + width = size, + height = size, strokeWidth, absoluteStrokeWidth, + nonScalingStroke, children, - iconNode, + iconNode = [], + icon = { + node: iconNode, + size: 24, + }, className, testID, ...rest @@ -68,31 +74,28 @@ const Icon = forwardRef( size: contextSize = 24, strokeWidth: contextStrokeWidth = 2, absoluteStrokeWidth: contextAbsoluteStrokeWidth = false, + nonScalingStroke: contextNonScalingStroke = false, color: contextColor = 'currentColor', + className: contextClass = '', } = useLucideContext() ?? {}; - const calculatedStrokeWidth = - absoluteStrokeWidth ?? contextAbsoluteStrokeWidth - ? (Number(strokeWidth ?? contextStrokeWidth) * 24) / Number(size ?? contextSize) - : strokeWidth ?? contextStrokeWidth; + const hasAccessibleProp = Boolean(children) || hasA11yProp(rest); - const [, svgAttributes, builtIconNode = []] = buildLucideIconNode( - { - size: 24, - node: iconNode, - }, - { - color: color ?? contextColor ?? defaultAttributes.stroke, - size: size ?? contextSize ?? defaultAttributes.width, - strokeWidth: calculatedStrokeWidth, - className, - includeDefaultClasses: false, - }, - ); + const [, svgAttributes, builtIconNode] = buildLucideIconForReact(icon, { + color: color ?? contextColor, + width: width ?? size ?? contextSize, + height: height ?? size ?? contextSize, + strokeWidth: strokeWidth ?? contextStrokeWidth, + absoluteStrokeWidth: absoluteStrokeWidth ?? contextAbsoluteStrokeWidth, + nonScalingStroke: nonScalingStroke ?? contextNonScalingStroke, + className: mergeClasses(contextClass, className), + hasA11yProp: hasAccessibleProp, + attributes: rest, + }); const customAttrs = { - stroke: color ?? contextColor ?? defaultAttributes.stroke, - strokeWidth: calculatedStrokeWidth, + stroke: svgAttributes['stroke'], + strokeWidth: svgAttributes['strokeWidth'], ...rest, }; diff --git a/packages/lucide-react-native/src/context.ts b/packages/lucide-react-native/src/context.ts index c63d8a412..3dd87faa8 100644 --- a/packages/lucide-react-native/src/context.ts +++ b/packages/lucide-react-native/src/context.ts @@ -4,12 +4,17 @@ const LucideContext = createContext<{ size?: number; color?: string; strokeWidth?: number; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + nonScalingStroke?: boolean; }>({ size: 24, color: 'currentColor', strokeWidth: 2, absoluteStrokeWidth: false, + nonScalingStroke: false, }); interface LucideProviderProps { @@ -17,7 +22,11 @@ interface LucideProviderProps { size?: number; color?: string; strokeWidth?: number; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + nonScalingStroke?: boolean; } export function LucideProvider({ @@ -26,6 +35,7 @@ export function LucideProvider({ color, strokeWidth, absoluteStrokeWidth, + nonScalingStroke, }: LucideProviderProps) { const value = useMemo( () => ({ @@ -33,8 +43,9 @@ export function LucideProvider({ color, strokeWidth, absoluteStrokeWidth, + nonScalingStroke, }), - [size, color, strokeWidth, absoluteStrokeWidth], + [size, color, strokeWidth, absoluteStrokeWidth, nonScalingStroke], ); return createElement(LucideContext.Provider, { value }, children); diff --git a/packages/lucide-react-native/src/createLucideIcon.ts b/packages/lucide-react-native/src/createLucideIcon.ts index 31f98f974..058deba06 100644 --- a/packages/lucide-react-native/src/createLucideIcon.ts +++ b/packages/lucide-react-native/src/createLucideIcon.ts @@ -6,10 +6,10 @@ import Icon from './Icon'; /** * Creates a Lucide icon component from icon data. * - * @param iconData Icon data object. + * @param icon Icon data object. * @returns Lucide icon component. */ -function createLucideIcon(iconData: LucideIconData): LucideIcon; +function createLucideIcon(icon: LucideIconData): LucideIcon; /** * Creates a Lucide icon component from the legacy icon arguments. @@ -38,7 +38,7 @@ function createLucideIcon( const Component = forwardRef((props, ref) => createElement(Icon, { ref, - iconNode: iconData.node, + icon: iconData, ...props, }), ); diff --git a/packages/lucide-react-native/src/defaultAttributes.ts b/packages/lucide-react-native/src/defaultAttributes.ts index 3fae7d973..6f55b9edc 100644 --- a/packages/lucide-react-native/src/defaultAttributes.ts +++ b/packages/lucide-react-native/src/defaultAttributes.ts @@ -1,21 +1,10 @@ -const defaultAttributes = { - xmlns: 'http://www.w3.org/2000/svg', - width: 24, - height: 24, - viewBox: '0 0 24 24', - fill: 'none', - stroke: 'currentColor', - strokeWidth: 2, - strokeLinecap: 'round', - strokeLinejoin: 'round', -}; +import { defaultReactAttributes } from '@lucide/shared'; export const childDefaultAttributes = { - fill: defaultAttributes.fill, - stroke: defaultAttributes.stroke, - strokeWidth: defaultAttributes.strokeWidth, - strokeLinecap: defaultAttributes.strokeLinecap, - strokeLinejoin: defaultAttributes.strokeLinejoin, + fill: defaultReactAttributes.fill, + stroke: defaultReactAttributes.stroke, + strokeWidth: defaultReactAttributes.strokeWidth, + strokeLinecap: defaultReactAttributes.strokeLinecap, + strokeLinejoin: defaultReactAttributes.strokeLinejoin, }; - -export default defaultAttributes; +export default defaultReactAttributes; diff --git a/packages/lucide-react-native/src/types.ts b/packages/lucide-react-native/src/types.ts index 993c3b720..fc3dfe772 100644 --- a/packages/lucide-react-native/src/types.ts +++ b/packages/lucide-react-native/src/types.ts @@ -3,23 +3,26 @@ import type { SvgProps } from 'react-native-svg'; import type { LucideIconData as SharedLucideIconData, LucideIconNode as SharedLucideIconNode, -} from '@lucide/shared'; +} from '@lucide/shared/types'; -export type LucideIconNode = SharedLucideIconNode[]; +export type LucideIconNode = SharedLucideIconNode; -export type LucideIconData = SharedLucideIconData & { - name: string; - node: LucideIconNode; -}; +export type LucideIconData = SharedLucideIconData; /** * @deprecated Use LucideIconNode instead. */ -export type IconNode = LucideIconNode; +export type IconNode = LucideIconNode[]; export interface LucideProps extends SvgProps { size?: string | number; + width?: string | number; + height?: string | number; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + nonScalingStroke?: boolean; 'data-testid'?: string; } diff --git a/packages/lucide-react-native/tests/Icon.spec.tsx b/packages/lucide-react-native/tests/Icon.spec.tsx index 0bf4e886a..5c55e0b1c 100644 --- a/packages/lucide-react-native/tests/Icon.spec.tsx +++ b/packages/lucide-react-native/tests/Icon.spec.tsx @@ -7,6 +7,8 @@ import { Icon } from '../src/lucide-react-native'; vi.mock('react-native-svg'); describe('Using Icon Component', () => { + const airVentIcon = { name: 'air-vent', node: airVent }; + it('should render icon based on a iconNode', async () => { const { container } = render( { expect(container.firstChild).toMatchSnapshot(); }); + + it('should render icon based on icon data', async () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeDefined(); + }); + + it('should support nonScalingStroke', async () => { + const { container } = render( + , + ); + + expect(container.firstChild?.firstChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + }); }); diff --git a/packages/lucide-react-native/tests/__snapshots__/Icon.spec.tsx.snap b/packages/lucide-react-native/tests/__snapshots__/Icon.spec.tsx.snap index fd704741d..819ed9daf 100644 --- a/packages/lucide-react-native/tests/__snapshots__/Icon.spec.tsx.snap +++ b/packages/lucide-react-native/tests/__snapshots__/Icon.spec.tsx.snap @@ -2,6 +2,8 @@ exports[`Using Icon Component > should render icon and match snapshot 1`] = ` `; + +exports[`Using lucide icon components > should scale the strokeWidth when absoluteStrokeWidth is set 1`] = ` + +`; diff --git a/packages/lucide-react/tests/context.spec.tsx b/packages/lucide-react/tests/context.spec.tsx index e6cd56e28..5b8e6cd76 100644 --- a/packages/lucide-react/tests/context.spec.tsx +++ b/packages/lucide-react/tests/context.spec.tsx @@ -60,6 +60,24 @@ describe('Using LucideProvider', () => { const IconComponent = container.firstElementChild; expect(IconComponent).toHaveAttribute('stroke-width', '1'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render the icon with LucideProvider and custom nonScalingStroke', () => { + const { container } = render( + + + , + ); + + const IconComponent = container.firstElementChild; + + expect(IconComponent?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + expect(container.firstChild).toMatchSnapshot(); }); it("should override the provider's global props when passing props to the icon", () => { diff --git a/packages/lucide-react/tests/lucide-react.spec.tsx b/packages/lucide-react/tests/lucide-react.spec.tsx index 8365d96f4..9b7480e03 100644 --- a/packages/lucide-react/tests/lucide-react.spec.tsx +++ b/packages/lucide-react/tests/lucide-react.spec.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { render, cleanup } from '@testing-library/react'; -import { Pen, Edit2, Grid, Droplet } from '../src/lucide-react'; +import { Pen, Edit2, Grid, createLucideIcon, LucideIconData } from '../src/lucide-react'; import defaultAttributes from '../src/defaultAttributes'; describe('Using lucide icon components', () => { @@ -69,7 +69,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, getByTestId } = render( { expect(container.innerHTML).toMatchSnapshot(); }); + it('should apply vector-effect when nonScalingStroke is set', () => { + const { container, getByTestId } = render( + , + ); + + const SVGElement = container.firstElementChild; + + expect(SVGElement).toHaveAttribute('stroke', 'red'); + expect(SVGElement).toHaveAttribute('width', '48'); + expect(SVGElement).toHaveAttribute('height', '48'); + expect(SVGElement).toHaveAttribute('stroke-width', '2'); + expect(SVGElement?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + + expect(container.innerHTML).toMatchSnapshot(); + }); + it('should apply all classNames to the element', () => { + const iconData: LucideIconData = { + name: 'droplet', + size: 24, + node: [ + [ + 'path', + { + d: 'M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z', + key: 'abc123', + }, + ], + ], + aliases: ['drop'], + }; const testClass = 'my-class'; - const { container } = render(); + const Icon = createLucideIcon(iconData); + const { container } = render(); expect(container.firstChild).toHaveClass(testClass); expect(container.firstChild).toHaveClass('lucide'); expect(container.firstChild).toHaveClass('lucide-droplet'); + expect(container.firstChild).toHaveClass('lucide-drop'); }); }); diff --git a/packages/lucide-react/tsconfig.json b/packages/lucide-react/tsconfig.json index 43f70ec12..bd959bbec 100644 --- a/packages/lucide-react/tsconfig.json +++ b/packages/lucide-react/tsconfig.json @@ -8,6 +8,11 @@ "moduleResolution": "node", "module": "ESNext", "target": "ESNext", + "baseUrl": ".", + "paths": { + "@lucide/shared": ["../shared/src/index.ts"], + "@lucide/shared/types": ["../shared/src/build/types.ts"], + }, "esModuleInterop": true, "isolatedModules": true, "lib": ["esnext", "dom"], @@ -19,7 +24,7 @@ "sourceMap": true, "outDir": "./dist", "jsx": "react-jsx", - "types": ["@testing-library/jest-dom"], + "types": ["@testing-library/jest-dom", "node"], }, "exclude": ["**/node_modules"], "include": ["src", "tests"], diff --git a/packages/lucide-solid/scripts/exportTemplate.mts b/packages/lucide-solid/scripts/exportTemplate.mts index 8bf2bb3c1..6a2b6d376 100644 --- a/packages/lucide-solid/scripts/exportTemplate.mts +++ b/packages/lucide-solid/scripts/exportTemplate.mts @@ -24,7 +24,7 @@ const iconData: LucideIconData = ${JSON.stringify(iconData)}; * ${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 51df3fe23..2183f4053 100644 --- a/packages/lucide-solid/src/Icon.tsx +++ b/packages/lucide-solid/src/Icon.tsx @@ -1,77 +1,58 @@ 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 { LucideIconData, LucideIconNode, LucideProps } from './types'; import { LucideContext } from './context'; -interface IconProps { - name?: string; - aliases?: string[]; - iconNode: IconNode; -} +type IconProps = + | { + icon: LucideIconData; + iconNode?: never; + } + | { + icon?: never; + iconNode?: LucideIconNode[]; + }; const Icon = (props: LucideProps & IconProps) => { const [localProps, rest] = splitProps(props, [ 'color', 'size', + 'width', + 'height', 'strokeWidth', 'children', 'class', - 'name', - 'aliases', + 'icon', 'iconNode', 'absoluteStrokeWidth', + 'nonScalingStroke', ]); 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, + const icon: LucideIconData = localProps.icon ?? { + node: localProps.iconNode ?? ([] as LucideIconNode[]), + size: 24, + aliases: [], }; + const [, svgAttributes, builtIconNode] = buildLucideIconNode(icon, { + color: localProps.color ?? globalProps.color, + width: localProps.width ?? localProps.size ?? globalProps.size, + height: localProps.height ?? localProps.size ?? globalProps.size, + strokeWidth: localProps.strokeWidth ?? globalProps.strokeWidth, + absoluteStrokeWidth: localProps.absoluteStrokeWidth ?? globalProps.absoluteStrokeWidth, + nonScalingStroke: localProps.nonScalingStroke ?? globalProps.nonScalingStroke, + className: mergeClasses('lucide-icon', globalProps.class, localProps.class), + hasA11yProp: hasAccessibleProp, + attributes: rest, + }); + return ( - + {([elementName, attrs]) => { return ( diff --git a/packages/lucide-solid/src/context.tsx b/packages/lucide-solid/src/context.tsx index 709de0cc9..c12f31109 100644 --- a/packages/lucide-solid/src/context.tsx +++ b/packages/lucide-solid/src/context.tsx @@ -4,13 +4,18 @@ export const LucideContext = createContext<{ size?: number; color?: string; strokeWidth?: number; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + nonScalingStroke?: boolean; class?: string; }>({ size: 24, color: 'currentColor', strokeWidth: 2, absoluteStrokeWidth: false, + nonScalingStroke: false, class: '', }); @@ -19,7 +24,11 @@ interface LucideProviderProps { size?: number; color?: string; strokeWidth?: number; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + nonScalingStroke?: boolean; class?: string; } @@ -29,6 +38,7 @@ export function LucideProvider(props: LucideProviderProps) { 'color', 'strokeWidth', 'absoluteStrokeWidth', + 'nonScalingStroke', 'class', ]); diff --git a/packages/lucide-solid/src/defaultAttributes.ts b/packages/lucide-solid/src/defaultAttributes.ts index 092994586..cc4036fbd 100644 --- a/packages/lucide-solid/src/defaultAttributes.ts +++ b/packages/lucide-solid/src/defaultAttributes.ts @@ -1,16 +1 @@ -import { JSX } from 'solid-js/jsx-runtime'; -import { 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; +export { defaultAttributes as default } from '@lucide/shared'; diff --git a/packages/lucide-solid/src/types.ts b/packages/lucide-solid/src/types.ts index 343e5b5c0..209acad63 100644 --- a/packages/lucide-solid/src/types.ts +++ b/packages/lucide-solid/src/types.ts @@ -1,20 +1,33 @@ import { JSX } from 'solid-js/jsx-runtime'; +import type { + LucideIconData as SharedLucideIconData, + LucideIconNode as SharedLucideIconNode, +} from '@lucide/shared/types'; -export type IconNode = [elementName: keyof JSX.IntrinsicElements, attrs: Record][]; -export interface LucideIconData { - name: string; - node: IconNode; - aliases?: string[]; -} export type SVGAttributes = Partial>; +export type LucideIconNode = SharedLucideIconNode; + +export type LucideIconData = SharedLucideIconData; + +/** + * @deprecated Use LucideIconNode instead. + */ +export type IconNode = LucideIconNode[]; + export interface LucideProps extends SVGAttributes { key?: string | number; class?: string; size?: string | number; + width?: string | number; + height?: string | number; color?: string; strokeWidth?: string | number; + /** + * @deprecated Use `nonScalingStroke` instead. + */ absoluteStrokeWidth?: boolean; + nonScalingStroke?: boolean; } export type LucideIcon = (props: LucideProps) => JSX.Element; diff --git a/packages/lucide-solid/tests/Icon.spec.tsx b/packages/lucide-solid/tests/Icon.spec.tsx index b10dd6211..42d274018 100644 --- a/packages/lucide-solid/tests/Icon.spec.tsx +++ b/packages/lucide-solid/tests/Icon.spec.tsx @@ -5,6 +5,8 @@ import { airVent } from './testIconNodes'; import { Icon } from '../src/lucide-solid'; describe('Using Icon Component', () => { + const airVentIcon = { name: 'air-vent', node: airVent }; + it('should render icon based on a iconNode', async () => { const { container } = render(() => ( { expect(container.firstChild).toMatchSnapshot(); }); + + it('should render icon based on icon data', async () => { + const { container } = render(() => ( + + )); + + expect(container.firstChild).toBeDefined(); + }); + + it('should support nonScalingStroke', async () => { + const { container } = render(() => ( + + )); + + expect(container.firstChild?.firstChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + }); }); describe('Icon Component Accessibility', () => { diff --git a/packages/lucide-solid/tests/__snapshots__/lucide-solid.spec.tsx.snap b/packages/lucide-solid/tests/__snapshots__/lucide-solid.spec.tsx.snap index 230c23a4a..22743b33f 100644 --- a/packages/lucide-solid/tests/__snapshots__/lucide-solid.spec.tsx.snap +++ b/packages/lucide-solid/tests/__snapshots__/lucide-solid.spec.tsx.snap @@ -2,55 +2,14 @@ exports[`Using lucide icon components > should adjust the size, stroke color and stroke width 1`] = ` -`; - -exports[`Using lucide icon components > should not scale the strokeWidth when absoluteStrokeWidth is set 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 ec0cb6604..5e8dda33a 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, 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/src/Icon.ts b/packages/vue/src/Icon.ts index 5bdecdb5f..d9f826371 100644 --- a/packages/vue/src/Icon.ts +++ b/packages/vue/src/Icon.ts @@ -1,26 +1,48 @@ -import { computed, type FunctionalComponent, h } from 'vue'; -import { buildLucideIconNode, isEmptyString, toKebabCase } from '@lucide/shared'; +import { type FunctionalComponent, h } from 'vue'; +import { + buildLucideIconNode, + hasA11yProp, + isEmptyString, + mergeClasses, + toKebabCase, +} from '@lucide/shared'; import defaultAttributes from './defaultAttributes'; -import { IconNode, LucideProps } from './types'; +import { LucideIconNode, LucideIconData, LucideProps } from './types'; import { useLucideProps } from './context'; -interface IconProps { - iconNode: IconNode; - name: string; - aliases?: string[]; -} +type IconProps = + | { + icon: LucideIconData; + iconNode?: never; + name?: never; + } + | { + icon?: never; + iconNode: LucideIconNode[]; + name: string; + }; const Icon: FunctionalComponent = ( { name, - aliases, - iconNode, + iconNode = [], + icon = { + name: toKebabCase(name), + node: iconNode, + 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 }, @@ -30,62 +52,43 @@ 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 [, svgAttributes, builtIconNode] = buildLucideIconNode(icon, { + color: color ?? contextColor, + width: width ?? size ?? contextSize, + height: height ?? size ?? contextSize, + strokeWidth: + strokeWidth ?? + strokeWidthKebabCase ?? + contextStrokeWidth ?? + defaultAttributes['stroke-width'], + absoluteStrokeWidth: isAbsoluteStrokeWidth, + nonScalingStroke: isNonScalingStroke, + className: mergeClasses(contextClass, propsClass), + hasA11yProp: !!slots || hasA11yProp(restProps), + attributes: restProps, }); - const normalizedName = computed(() => (name != null ? toKebabCase(name) : undefined)); - - const iconName = computed(() => (normalizedName.value ? `${normalizedName.value}-icon` : 'icon')); - - const [, svgAttributes, builtIconNode = []] = buildLucideIconNode( - { - name: iconName.value, - aliases, - size: 24, - node: iconNode, - }, - { - size: size ?? contextSize ?? defaultAttributes.width, - color: color ?? contextColor ?? defaultAttributes.stroke, - strokeWidth: calculatedStrokeWidth.value, - className: contextClass, - }, - ); - - 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, [ + return h('svg', svgAttributes, [ ...builtIconNode.map((child) => h(...child)), ...(slots.default ? [slots.default()] : []), ]); 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 10d5247df..5981b8153 100644 --- a/packages/vue/src/createLucideIcon.ts +++ b/packages/vue/src/createLucideIcon.ts @@ -1,25 +1,44 @@ import { h } from 'vue'; -import { LucideIcon, LucideIconData } from './types'; +import { LucideIcon, LucideIconData, LucideIconNode } from './types'; import Icon from './Icon'; /** - * Create a Lucide icon component - * @param {object} iconData - * @returns {FunctionalComponent} LucideIcon + * Creates a Lucide icon component from icon data. + * + * @param icon Icon data object. + * @returns Lucide icon component. */ -const createLucideIcon = - (iconData: LucideIconData): 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: iconData.node, - name: iconData.name, - aliases: iconData.aliases, + 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 38d5f2f6d..f292be26a 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -1,16 +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 interface LucideIconData { - name: string; - node: IconNode; - aliases?: string[]; -} +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 968a36d05..54c3c394e 100644 --- a/packages/vue/tests/Icon.spec.ts +++ b/packages/vue/tests/Icon.spec.ts @@ -5,21 +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 { container } = render(Icon, { - props: { - iconNode: airVent, - name: 'AirVent', - size: 48, - color: 'red', - absoluteStrokeWidth: true, - }, - }); + const airVentIcon = { name: 'air-vent', node: airVent, size: 24 }; - expect(container.firstChild).toBeDefined(); - }); - - it('should render icon and match snapshot', async () => { + it('should render icon based on an icon node', async () => { const { container } = render(Icon, { props: { iconNode: airVent, @@ -32,4 +20,42 @@ describe('Using Icon Component', () => { expect(container.firstChild).toMatchSnapshot(); }); + + it('should render icon based on icon data', async () => { + const { container } = render(Icon, { + props: { + icon: airVentIcon, + size: 48, + color: 'red', + absoluteStrokeWidth: true, + }, + }); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render icon and match snapshot', async () => { + const { container } = render(Icon, { + props: { + icon: airVentIcon, + size: 48, + color: 'red', + absoluteStrokeWidth: true, + }, + }); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render icon based on icon data', async () => { + const { container } = render(Icon, { + props: { + icon: airVentIcon, + size: 48, + color: 'red', + }, + }); + + expect(container.firstChild).toBeDefined(); + }); }); diff --git a/packages/vue/tests/__snapshots__/Icon.spec.ts.snap b/packages/vue/tests/__snapshots__/Icon.spec.ts.snap index 9907eb584..a765d4e6e 100644 --- a/packages/vue/tests/__snapshots__/Icon.spec.ts.snap +++ b/packages/vue/tests/__snapshots__/Icon.spec.ts.snap @@ -2,7 +2,63 @@ exports[`Using Icon Component > should render icon and match snapshot 1`] = ` + + + + + +`; + +exports[`Using Icon Component > should render icon based on an icon node 1`] = ` + + + + + + +`; + +exports[`Using Icon Component > should render icon based on icon data 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 lucide-house-icon lucide-home provider-class icon-class', + 'lucide lucide-house lucide-home provider-class icon-class', ); }); }); diff --git a/packages/vue/tests/lucide-vue.spec.ts b/packages/vue/tests/lucide-vue.spec.ts index bde2e0fd5..cf2b536fa 100644 --- a/packages/vue/tests/lucide-vue.spec.ts +++ b/packages/vue/tests/lucide-vue.spec.ts @@ -59,7 +59,7 @@ describe('Using lucide icon components', () => { expect(icon).toHaveClass('my-icon'); expect(icon).toHaveClass('lucide'); - expect(icon).toHaveClass('lucide-smile-icon'); + expect(icon).toHaveClass('lucide-smile'); }); it('should add a style attribute to the element', () => { @@ -152,7 +152,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(Pen, { props: { size: 48, @@ -168,7 +168,7 @@ describe('Using lucide icon components', () => { expect(icon).toHaveAttribute('stroke-width', '1'); }); - it('should not scale the strokeWidth when absoluteStrokeWidth is as empty value attribute', () => { + it('should scale the strokeWidth when absoluteStrokeWidth is as empty value attribute', () => { const { container } = render(Pen, { props: { size: 48, @@ -184,7 +184,7 @@ describe('Using lucide icon components', () => { expect(icon).toHaveAttribute('stroke-width', '1'); }); - it('should not scale the strokeWidth when absoluteStrokeWidth is written in kebabCase', () => { + it('should scale the strokeWidth when absoluteStrokeWidth is written in kebabCase', () => { const { container } = render(Pen, { props: { size: 48, @@ -200,4 +200,56 @@ describe('Using lucide icon components', () => { expect(icon).toHaveAttribute('stroke', 'red'); expect(icon).toHaveAttribute('stroke-width', '1'); }); + + it('should apply vector-effect when nonScalingStroke is set', () => { + const { container } = render(Pen, { + props: { + size: 48, + color: 'red', + nonScalingStroke: true, + }, + }); + + const icon = container.firstElementChild; + + expect(icon).toHaveAttribute('width', '48'); + expect(icon).toHaveAttribute('stroke', 'red'); + expect(icon).toHaveAttribute('stroke-width', '2'); + expect(icon?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + }); + + it('should apply vector-effect when nonScalingStroke is as empty value attribute', () => { + const { container } = render(Pen, { + props: { + size: 48, + color: 'red', + nonScalingStroke: '' as unknown as boolean, // Vue treats empty string as true for boolean props + }, + }); + + const icon = container.firstElementChild; + + expect(icon).toHaveAttribute('width', '48'); + expect(icon).toHaveAttribute('stroke', 'red'); + expect(icon).toHaveAttribute('stroke-width', '2'); + expect(icon?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + }); + + it('should apply vector-effect when nonScalingStroke is written in kebabCase', () => { + const { container } = render(Pen, { + props: { + size: 48, + color: 'red', + 'stroke-width': '2', + 'non-scaling-stroke': '' as unknown as boolean, // Vue treats empty string as true for boolean props + }, + }); + + const icon = container.firstElementChild; + + expect(icon).toHaveAttribute('width', '48'); + expect(icon).toHaveAttribute('stroke', 'red'); + expect(icon).toHaveAttribute('stroke-width', '2'); + expect(icon?.firstElementChild).toHaveAttribute('vector-effect', 'non-scaling-stroke'); + }); }); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index c38e018fa..dba73c989 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -4,6 +4,11 @@ "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Node", + "baseUrl": ".", + "paths": { + "@lucide/shared": ["../shared/src/index.ts"], + "@lucide/shared/types": ["../shared/src/build/types.ts"], + }, "strict": true, "jsx": "preserve", "resolveJsonModule": true,