feat(): unify icon build logic some more, add nonScalingStroke option, marking absoluteStrokeWidth as deprecated

This commit is contained in:
Karsa
2026-06-05 16:49:09 +02:00
parent f5437f0a4c
commit 8d0fa2e218
102 changed files with 1809 additions and 947 deletions
+1 -1
View File
@@ -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",
+7 -1
View File
@@ -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,
};
/**
+42 -5
View File
@@ -41,6 +41,7 @@ describe('LucideIconBase', () => {
let size: WritableSignal<string | number | undefined>;
let strokeWidth: WritableSignal<string | number | undefined>;
let absoluteStrokeWidth: WritableSignal<boolean | undefined>;
let nonScalingStroke: WritableSignal<boolean | undefined>;
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();
}
});
});
});
});
+109 -31
View File
@@ -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<string | number>) => 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<string | number>) => 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<boolean>) => 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<boolean>) => 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<LucideIconNode[1] | undefined>(
() => 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);
}
});
}
});
+10 -8
View File
@@ -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<Nullable<number | string>>;
color: Signal<Nullable<string>>;
strokeWidth: Signal<Nullable<number | string>>;
/**
* @deprecated Use `nonScalingStroke` instead.
*/
absoluteStrokeWidth: Signal<Nullable<boolean>>;
nonScalingStroke: Signal<Nullable<boolean>>;
}
/**