mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-02-24 04:39:39 +01:00
feat(packages/angular): switched to self-describing IconData object from separate node+name – no more toKebabCase hackery
feat(packages/angular): renamed LucideIconComponentType => LucideIcon, and LucideIcon => LucideDynamicIcon
feat(packages/angular): added backwards compatible CSS class support
feat(packages/angular): switched to vector-effect: non-scaling-stroke implementation from computed stroke width
feat(packages/angular): rewrote icon provider to only accept a list of self-described icons – no more toKebabCase hackery & as an added bonus automatic backwards compatible alias support 🚀
feat(packages/angular): added legacy icon node helper function for passing legacy icons to providers
test(packages/angular): added unit tests on LUCIDE_CONFIG provider usage
This commit is contained in:
@@ -8,3 +8,4 @@ node_modules
|
||||
docs/images
|
||||
docs/**/examples/
|
||||
packages/lucide-react/dynamicIconImports.js
|
||||
packages/angular/.angular
|
||||
|
||||
@@ -16,10 +16,10 @@ export default defineExportTemplate(async ({
|
||||
const angularComponentName = `Lucide${componentName}`;
|
||||
const selectors = [`svg[lucide${toPascalCase(iconName)}]`];
|
||||
const aliasComponentNames: string[] = [];
|
||||
for (const alias of aliases) {
|
||||
const aliasName = typeof alias === 'string' ? alias : alias.name;
|
||||
const aliasComponentName = `Lucide${toPascalCase(aliasName)}`;
|
||||
const aliasSelector = `svg[lucide${toPascalCase(aliasName)}]`;
|
||||
const aliasNames = aliases.map(alias => typeof alias === 'string' ? alias : alias.name);
|
||||
for (const alias of aliasNames) {
|
||||
const aliasComponentName = `Lucide${toPascalCase(alias)}`;
|
||||
const aliasSelector = `svg[lucide${toPascalCase(alias)}]`;
|
||||
if (!selectors.includes(aliasSelector)) {
|
||||
selectors.push(aliasSelector);
|
||||
}
|
||||
@@ -49,10 +49,12 @@ import { Component, signal } from '@angular/core';
|
||||
standalone: true,
|
||||
})
|
||||
export class ${angularComponentName} extends LucideIconBase {
|
||||
static readonly iconName = '${iconName}';
|
||||
static readonly iconData: LucideIconData = ${JSON.stringify(children)};
|
||||
protected override readonly iconName = signal(${angularComponentName}.iconName);
|
||||
protected override readonly iconData = signal(${angularComponentName}.iconData);
|
||||
static readonly icon: LucideIconData = ${JSON.stringify({
|
||||
name: iconName,
|
||||
node: children,
|
||||
...(aliasNames.length > 0 && { aliases: aliasNames }),
|
||||
})};
|
||||
protected override readonly icon = signal(${angularComponentName}.icon);
|
||||
}
|
||||
|
||||
${aliasComponentNames.map((aliasComponentName) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, input, inputBinding, signal, WritableSignal } from '@angular/core';
|
||||
import { LucideIcon } from './lucide-icon';
|
||||
import { LucideDynamicIcon } from './lucide-dynamic-icon';
|
||||
import { provideLucideConfig } from './lucide-config';
|
||||
import { LucideIconData, LucideIconInput } from './types';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideLucideIcons } from './lucide-icons';
|
||||
@@ -12,44 +13,39 @@ import { By } from '@angular/platform-browser';
|
||||
<rect x="1" y="1" width="22" height="22" />
|
||||
</svg>
|
||||
}`,
|
||||
imports: [LucideIcon],
|
||||
imports: [LucideDynamicIcon],
|
||||
})
|
||||
class TestHostComponent {
|
||||
readonly icon = input<LucideIconData>();
|
||||
}
|
||||
|
||||
describe('LucideIcon', () => {
|
||||
let component: LucideIcon;
|
||||
let fixture: ComponentFixture<LucideIcon>;
|
||||
describe('LucideDynamicIcon', () => {
|
||||
let component: LucideDynamicIcon;
|
||||
let fixture: ComponentFixture<LucideDynamicIcon>;
|
||||
let icon: WritableSignal<LucideIconInput | null | undefined>;
|
||||
let name: WritableSignal<string | undefined>;
|
||||
let title: WritableSignal<string | undefined>;
|
||||
let color: WritableSignal<string | undefined>;
|
||||
let size: WritableSignal<string | number | undefined>;
|
||||
let strokeWidth: WritableSignal<string | number | undefined>;
|
||||
let absoluteStrokeWidth: WritableSignal<boolean | undefined>;
|
||||
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
|
||||
const testIcon: LucideIconData = [['polyline', { points: '1 1 22 22' }]];
|
||||
const testIcon2: LucideIconData = [
|
||||
['circle', { cx: 12, cy: 12, r: 8 }],
|
||||
['polyline', { points: '1 1 22 22' }],
|
||||
];
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideLucideIcons({ demo: testIcon })],
|
||||
});
|
||||
icon = signal('demo');
|
||||
name = signal(undefined);
|
||||
title = signal(undefined);
|
||||
color = signal(undefined);
|
||||
size = signal(undefined);
|
||||
strokeWidth = signal(undefined);
|
||||
absoluteStrokeWidth = signal(undefined);
|
||||
fixture = TestBed.createComponent(LucideIcon, {
|
||||
const testIcon: LucideIconData = {
|
||||
name: 'demo',
|
||||
node: [['polyline', { points: '1 1 22 22' }]],
|
||||
};
|
||||
const testIcon2: LucideIconData = {
|
||||
name: 'demo-other',
|
||||
node: [
|
||||
['circle', { cx: 12, cy: 12, r: 8 }],
|
||||
['polyline', { points: '1 1 22 22' }],
|
||||
],
|
||||
aliases: ['demo-2'],
|
||||
};
|
||||
function createComponent() {
|
||||
return TestBed.createComponent(LucideDynamicIcon, {
|
||||
inferTagName: true,
|
||||
bindings: [
|
||||
inputBinding('lucideIcon', icon),
|
||||
inputBinding('name', name),
|
||||
inputBinding('title', title),
|
||||
inputBinding('color', color),
|
||||
inputBinding('size', size),
|
||||
@@ -57,6 +53,18 @@ describe('LucideIcon', () => {
|
||||
inputBinding('absoluteStrokeWidth', absoluteStrokeWidth),
|
||||
],
|
||||
});
|
||||
}
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideLucideIcons(testIcon)],
|
||||
});
|
||||
icon = signal('demo');
|
||||
title = signal(undefined);
|
||||
color = signal(undefined);
|
||||
size = signal(undefined);
|
||||
strokeWidth = signal(undefined);
|
||||
absoluteStrokeWidth = signal(undefined);
|
||||
fixture = createComponent();
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
@@ -82,30 +90,23 @@ describe('LucideIcon', () => {
|
||||
describe('iconInput', () => {
|
||||
it('should support LucideIconData input', () => {
|
||||
icon.set(testIcon);
|
||||
name.set('custom-name');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['iconData']()).toBe(testIcon);
|
||||
expect(component['iconName']()).toBe('custom-name');
|
||||
expect(component['icon']()).toBe(testIcon);
|
||||
expect(fixture.nativeElement.innerHTML).toBe(
|
||||
'<!--container--><polyline points="1 1 22 22"></polyline>',
|
||||
);
|
||||
});
|
||||
it('should support LucideIconComponentType input', () => {
|
||||
it('should support LucideIcon input', () => {
|
||||
icon.set(LucideActivity);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['iconData']()).toBe(LucideActivity.iconData);
|
||||
expect(component['iconName']()).toBe(LucideActivity.iconName);
|
||||
expect(component['icon']()).toBe(LucideActivity.icon);
|
||||
});
|
||||
it('should support string icon name', () => {
|
||||
icon.set('demo');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['iconData']()).toBe(testIcon);
|
||||
expect(component['iconName']()).toBe('demo');
|
||||
expect(component['icon']()).toBe(testIcon);
|
||||
});
|
||||
it('should throw error if no icon founds', () => {
|
||||
it('should throw error if no icon found', () => {
|
||||
icon.set('invalid');
|
||||
expect(() => fixture.detectChanges()).toThrowError(`Unable to resolve icon 'invalid'`);
|
||||
});
|
||||
@@ -116,12 +117,10 @@ describe('LucideIcon', () => {
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-demo');
|
||||
});
|
||||
it('should add class from name, even if icon has name', () => {
|
||||
icon.set(LucideActivity);
|
||||
name.set('custom-name');
|
||||
it('should add backwards compatible classes from aliases', () => {
|
||||
icon.set(testIcon2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-custom-name');
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-demo-other lucide-demo-2');
|
||||
});
|
||||
it('should add class icon if available', () => {
|
||||
icon.set(LucideActivity);
|
||||
@@ -195,16 +194,21 @@ describe('LucideIcon', () => {
|
||||
it('should not adjust stroke width', () => {
|
||||
strokeWidth.set(2);
|
||||
size.set(12);
|
||||
absoluteStrokeWidth.set(false);
|
||||
absoluteStrokeWidth.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('stroke-width')).toBe('2');
|
||||
});
|
||||
it('should adjust stroke width', () => {
|
||||
strokeWidth.set(2);
|
||||
size.set(12);
|
||||
it('should not set vector-effect on children', () => {
|
||||
absoluteStrokeWidth.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);
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('stroke-width')).toBe('4');
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,4 +244,72 @@ describe('LucideIcon', () => {
|
||||
expect(rect.outerHTML).toBe('<rect x="1" y="1" width="22" height="22"></rect>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LUCIDE_CONFIG', () => {
|
||||
beforeEach(async () => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideLucideIcons(testIcon),
|
||||
provideLucideConfig({
|
||||
color: 'red',
|
||||
strokeWidth: 1,
|
||||
size: 12,
|
||||
absoluteStrokeWidth: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await TestBed.compileComponents();
|
||||
fixture = createComponent();
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
describe('color', () => {
|
||||
it('should use color from config', () => {
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('stroke')).toBe('red');
|
||||
});
|
||||
it('should use override color from config', () => {
|
||||
color.set('pink');
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('stroke')).toBe('pink');
|
||||
});
|
||||
});
|
||||
describe('strokeWidth', () => {
|
||||
it('should use stroke width from config', () => {
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('stroke-width')).toBe('1');
|
||||
});
|
||||
it('should use override stroke width from config', () => {
|
||||
strokeWidth.set(3);
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('stroke-width')).toBe('3');
|
||||
});
|
||||
});
|
||||
describe('size', () => {
|
||||
it('should use size from config', () => {
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('width')).toBe('12');
|
||||
expect(getSvgAttribute('height')).toBe('12');
|
||||
});
|
||||
it('should use override size from config', () => {
|
||||
size.set('48');
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('width')).toBe('48');
|
||||
expect(getSvgAttribute('height')).toBe('48');
|
||||
});
|
||||
});
|
||||
describe('absoluteStrokeWidth', () => {
|
||||
it('should use absoluteStrokeWidth from config', () => {
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
}
|
||||
});
|
||||
it('should override absoluteStrokeWidth', () => {
|
||||
absoluteStrokeWidth.set(false);
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
expect(child.getAttribute('vector-effect')).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
33
packages/angular/src/lucide-dynamic-icon.ts
Normal file
33
packages/angular/src/lucide-dynamic-icon.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { isLucideIconComponent, isLucideIconData, LucideIconInput } from './types';
|
||||
import { LucideIconBase } from './lucide-icon-base';
|
||||
import { LUCIDE_ICONS } from './lucide-icons';
|
||||
|
||||
/**
|
||||
* Generic icon component for rendering LucideIconData.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'svg[lucideIcon]',
|
||||
templateUrl: './lucide-icon.html',
|
||||
standalone: true,
|
||||
})
|
||||
export class LucideDynamicIcon extends LucideIconBase {
|
||||
protected readonly icons = inject(LUCIDE_ICONS);
|
||||
public readonly lucideIcon = input.required<LucideIconInput | null>();
|
||||
|
||||
protected override readonly icon = computed(() => {
|
||||
const icon = this.lucideIcon();
|
||||
if (isLucideIconData(icon)) {
|
||||
return icon;
|
||||
} else if (isLucideIconComponent(icon)) {
|
||||
return icon.icon;
|
||||
} else if (typeof icon === 'string') {
|
||||
if (icon in this.icons) {
|
||||
return this.icons[icon];
|
||||
} else {
|
||||
throw new Error(`Unable to resolve icon '${icon}'`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
import { LUCIDE_CONFIG } from './lucide-config';
|
||||
import { LucideIconData, Nullable } from './types';
|
||||
import defaultAttributes from './default-attributes';
|
||||
import { formatFixed } from './utils/format-fixed';
|
||||
import { toKebabCase } from './utils/to-kebab-case';
|
||||
|
||||
function transformNumericStringInput(
|
||||
value: Nullable<string | number>,
|
||||
@@ -41,13 +39,12 @@ function transformNumericStringInput(
|
||||
'[attr.width]': 'size().toString(10)',
|
||||
'[attr.height]': 'size().toString(10)',
|
||||
'[attr.stroke]': 'color()',
|
||||
'[attr.stroke-width]': 'computedStrokeWidth()',
|
||||
'[attr.stroke-width]': 'strokeWidth().toString(10)',
|
||||
'[attr.aria-hidden]': '!title()',
|
||||
},
|
||||
})
|
||||
export abstract class LucideIconBase {
|
||||
protected abstract readonly iconName: Signal<Nullable<string>>;
|
||||
protected abstract readonly iconData: Signal<Nullable<LucideIconData>>;
|
||||
protected abstract readonly icon: Signal<Nullable<LucideIconData>>;
|
||||
protected readonly iconConfig = inject(LUCIDE_CONFIG);
|
||||
protected readonly elRef = inject(ElementRef);
|
||||
protected readonly renderer = inject(Renderer2);
|
||||
@@ -88,33 +85,27 @@ export abstract class LucideIconBase {
|
||||
transformNumericStringInput(value, this.iconConfig.strokeWidth),
|
||||
});
|
||||
/**
|
||||
* Whether stroke width should be scaled to appear uniform regardless of icon size.
|
||||
*
|
||||
* @remarks
|
||||
* Use CSS to set on SVG paths instead:
|
||||
* ```css
|
||||
* .lucide * {
|
||||
* vector-effect: non-scaling-stroke;
|
||||
* }
|
||||
* ```
|
||||
* 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 absoluteStrokeWidth = input(this.iconConfig.absoluteStrokeWidth, {
|
||||
transform: (value: Nullable<boolean>) => value ?? this.iconConfig.absoluteStrokeWidth,
|
||||
});
|
||||
protected readonly computedStrokeWidth = computed(() => {
|
||||
const strokeWidth = this.strokeWidth();
|
||||
const size = this.size();
|
||||
return this.absoluteStrokeWidth()
|
||||
? formatFixed(strokeWidth / (size / 24))
|
||||
: strokeWidth.toString(10);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const icon = this.iconData();
|
||||
const icon = this.icon();
|
||||
if (icon) {
|
||||
const elements = icon.map(([name, attrs]) => {
|
||||
const absoluteStrokeWidth = this.absoluteStrokeWidth();
|
||||
const { name, node, aliases = [] } = icon;
|
||||
const classes = [name, ...aliases].map((item) => `lucide-${item}`);
|
||||
for (const cssClass of classes) {
|
||||
this.renderer.addClass(this.elRef.nativeElement, cssClass);
|
||||
}
|
||||
const elements = node.map(([name, attrs]) => {
|
||||
const element = this.renderer.createElement(name, 'http://www.w3.org/2000/svg');
|
||||
if (absoluteStrokeWidth) {
|
||||
this.renderer.setAttribute(element, 'vector-effect', 'non-scaling-stroke');
|
||||
}
|
||||
Object.entries(attrs).forEach(([name, value]) =>
|
||||
this.renderer.setAttribute(
|
||||
element,
|
||||
@@ -129,16 +120,9 @@ export abstract class LucideIconBase {
|
||||
elements.forEach((element) =>
|
||||
this.renderer.removeChild(this.elRef.nativeElement, element),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
const name = this.iconName();
|
||||
if (name) {
|
||||
const cssClass = `lucide-${toKebabCase(name)}`;
|
||||
this.renderer.addClass(this.elRef.nativeElement, cssClass);
|
||||
onCleanup(() => {
|
||||
this.renderer.removeClass(this.elRef.nativeElement, cssClass);
|
||||
for (const cssClass of classes) {
|
||||
this.renderer.removeClass(this.elRef.nativeElement, cssClass);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { isLucideIconComponent, isLucideIconData, LucideIconInput } from './types';
|
||||
import { LucideIconBase } from './lucide-icon-base';
|
||||
import { LUCIDE_ICONS } from './lucide-icons';
|
||||
import { LucideIconData } from './types';
|
||||
import { toKebabCase } from './utils/to-kebab-case';
|
||||
|
||||
interface LucideResolvedIcon {
|
||||
name?: string | null;
|
||||
data: LucideIconData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic icon component for rendering LucideIconData.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'svg[lucideIcon]',
|
||||
templateUrl: './lucide-icon.html',
|
||||
standalone: true,
|
||||
})
|
||||
export class LucideIcon extends LucideIconBase {
|
||||
protected readonly icons = inject(LUCIDE_ICONS);
|
||||
readonly name = input<string | null>();
|
||||
readonly iconInput = input.required<LucideIconInput | null>({
|
||||
alias: 'lucideIcon',
|
||||
});
|
||||
readonly resolvedIcon = computed<LucideResolvedIcon | null>(() => {
|
||||
return this.resolveIcon(this.name(), this.iconInput());
|
||||
});
|
||||
protected override readonly iconName = computed(() => {
|
||||
return this.resolvedIcon()?.name;
|
||||
});
|
||||
protected override readonly iconData = computed(() => {
|
||||
return this.resolvedIcon()?.data;
|
||||
});
|
||||
|
||||
protected resolveIcon(
|
||||
name: string | null | undefined,
|
||||
icon: LucideIconInput | null | undefined,
|
||||
): LucideResolvedIcon | null {
|
||||
if (isLucideIconData(icon)) {
|
||||
return {
|
||||
name,
|
||||
data: icon,
|
||||
};
|
||||
} else if (isLucideIconComponent(icon)) {
|
||||
return {
|
||||
name: name ?? icon.iconName,
|
||||
data: icon.iconData,
|
||||
};
|
||||
} else if (typeof icon === 'string') {
|
||||
const name = toKebabCase(icon);
|
||||
if (name in this.icons) {
|
||||
return {
|
||||
name,
|
||||
data: this.icons[name],
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unable to resolve icon '${icon}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LUCIDE_ICONS, provideLucideIcons } from './lucide-icons';
|
||||
import {
|
||||
LUCIDE_ICONS,
|
||||
lucideLegacyIcon,
|
||||
lucideLegacyIconMap,
|
||||
provideLucideIcons,
|
||||
} from './lucide-icons';
|
||||
import { LucideIconData } from './types';
|
||||
import { LucideActivity } from './icons/activity';
|
||||
import { LucideCircle } from './icons/circle';
|
||||
@@ -12,32 +17,49 @@ describe('Lucide icons', () => {
|
||||
});
|
||||
});
|
||||
describe('provideLucideIcons', () => {
|
||||
const mockIcon: LucideIconData = [['polyline', { points: '1 1 22 22' }]];
|
||||
const mockIcon2: LucideIconData = [['circle', { cx: 12, cy: 12, r: 8 }]];
|
||||
it('should accept dictionary of icons', () => {
|
||||
const mockIcon: LucideIconData = {
|
||||
name: 'mock-icon',
|
||||
node: [['polyline', { points: '1 1 22 22' }]],
|
||||
};
|
||||
const mockIcon2: LucideIconData = {
|
||||
name: 'mock-icon-circle',
|
||||
node: [['circle', { cx: 12, cy: 12, r: 8 }]],
|
||||
aliases: ['mock-icon-2'],
|
||||
};
|
||||
const legacyIconNode: LucideIconData['node'] = [['circle', { cx: 12, cy: 12, r: 8 }]];
|
||||
const legacyAlias = 'legacy-old-name';
|
||||
const OtherLegacyIcon = legacyIconNode;
|
||||
it('should accept list of icon object, icon components or legacy icons', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideLucideIcons({
|
||||
DemoIcon: mockIcon,
|
||||
MockIcon: mockIcon2,
|
||||
TestIcon: LucideActivity,
|
||||
}),
|
||||
provideLucideIcons(
|
||||
mockIcon,
|
||||
mockIcon2,
|
||||
LucideCircle,
|
||||
lucideLegacyIcon('legacy-icon', legacyIconNode, [legacyAlias]),
|
||||
...lucideLegacyIconMap({ OtherLegacyIcon }),
|
||||
),
|
||||
],
|
||||
});
|
||||
const legacyIconData = {
|
||||
name: 'legacy-icon',
|
||||
node: legacyIconNode,
|
||||
aliases: [legacyAlias],
|
||||
};
|
||||
const otherLegacyIconData = {
|
||||
name: 'other-legacy-icon',
|
||||
node: legacyIconNode,
|
||||
aliases: ['OtherLegacyIcon'],
|
||||
};
|
||||
expect(TestBed.inject(LUCIDE_ICONS)).toEqual({
|
||||
'demo-icon': mockIcon,
|
||||
'mock-icon': mockIcon2,
|
||||
[LucideActivity.iconName]: LucideActivity.iconData,
|
||||
});
|
||||
});
|
||||
it('should accept list of icon components', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideLucideIcons([LucideActivity, LucideSquareX, LucideCircle])],
|
||||
});
|
||||
expect(TestBed.inject(LUCIDE_ICONS)).toEqual({
|
||||
[LucideActivity.iconName]: LucideActivity.iconData,
|
||||
[LucideSquareX.iconName]: LucideSquareX.iconData,
|
||||
[LucideCircle.iconName]: LucideCircle.iconData,
|
||||
'mock-icon': mockIcon,
|
||||
'mock-icon-circle': mockIcon2,
|
||||
'mock-icon-2': mockIcon2,
|
||||
'legacy-icon': legacyIconData,
|
||||
'legacy-old-name': legacyIconData,
|
||||
'other-legacy-icon': otherLegacyIconData,
|
||||
OtherLegacyIcon: otherLegacyIconData,
|
||||
['circle']: LucideCircle.icon,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { InjectionToken, Provider } from '@angular/core';
|
||||
import { LucideIconData, LucideIcons } from './types';
|
||||
import { isLucideIconComponent, LucideIconComponentType } from './types';
|
||||
import { toKebabCase } from './utils/to-kebab-case';
|
||||
import { isLucideIconComponent, LucideIcon, LucideIconData, LucideIcons } from './types';
|
||||
|
||||
/**
|
||||
* Injection token for providing Lucide icons by name.
|
||||
@@ -23,7 +21,7 @@ export const LUCIDE_ICONS = new InjectionToken<LucideIcons>('Lucide icons', {
|
||||
* @usage
|
||||
* ```ts
|
||||
* import { provideLucideIcons, SquareCheck } from '@lucide/angular';
|
||||
* import { MyCustomIcon } from './custom-icons/circle-check';
|
||||
* import { MyCustomIcon } from './custom-icons/my-custom-icon';
|
||||
*
|
||||
* providers: [
|
||||
* provideLucideIcons({
|
||||
@@ -37,28 +35,71 @@ export const LUCIDE_ICONS = new InjectionToken<LucideIcons>('Lucide icons', {
|
||||
* <svg lucideIcon="my-custom-icon" />
|
||||
* ```
|
||||
*/
|
||||
export function provideLucideIcons(
|
||||
icons: Record<string, LucideIconData | LucideIconComponentType> | Array<LucideIconComponentType>,
|
||||
): Provider {
|
||||
if (Array.isArray(icons)) {
|
||||
return {
|
||||
provide: LUCIDE_ICONS,
|
||||
useValue: icons.reduce((acc, icon) => {
|
||||
acc[toKebabCase(icon.iconName)] = icon.iconData;
|
||||
return acc;
|
||||
}, {} as LucideIcons),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
provide: LUCIDE_ICONS,
|
||||
useValue: Object.entries(icons).reduce((acc, [name, icon]) => {
|
||||
if (isLucideIconComponent(icon)) {
|
||||
acc[icon.iconName] = icon.iconData;
|
||||
} else {
|
||||
acc[toKebabCase(name)] = icon;
|
||||
}
|
||||
return acc;
|
||||
}, {} as LucideIcons),
|
||||
};
|
||||
}
|
||||
export function provideLucideIcons(...icons: Array<LucideIcon | LucideIconData>): Provider {
|
||||
return {
|
||||
provide: LUCIDE_ICONS,
|
||||
useValue: icons.reduce((acc, icon) => {
|
||||
const iconData = isLucideIconComponent(icon) ? icon.icon : icon;
|
||||
acc[iconData.name] = iconData;
|
||||
for (const alias of iconData.aliases ?? []) {
|
||||
acc[alias] = iconData;
|
||||
}
|
||||
return acc;
|
||||
}, {} as LucideIcons),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a legacy icon node to the new format, for custom icon (e.g. `@lucide/lab`) support.
|
||||
*
|
||||
* @usage
|
||||
* ```ts
|
||||
* import { provideLucideIcons, lucideLegacyIcon } from '@lucide/angular';
|
||||
* import { UserRoundX } from 'lucide-angular';
|
||||
* import { burger } from '@lucide/lab';
|
||||
*
|
||||
* provideLucideIcons(
|
||||
* lucideLegacyIcon('user-round-x', UserRoundX, ['user-circle-x']),
|
||||
* lucideLegacyIcon('burger', burger, ['hamburger']),
|
||||
* ),
|
||||
* ```
|
||||
*/
|
||||
export function lucideLegacyIcon(
|
||||
name: string,
|
||||
node: LucideIconData['node'],
|
||||
aliases: string[] = [],
|
||||
): LucideIconData {
|
||||
return {
|
||||
name,
|
||||
node,
|
||||
aliases,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a map of legacy icon nodes to a list of icon data objects.
|
||||
*
|
||||
* @usage
|
||||
* ```ts
|
||||
* import { provideLucideIcons, lucideLegacyIconMap, LucideCircle } from '@lucide/angular';
|
||||
* import { UserRoundX } from 'lucide-angular';
|
||||
* import { burger } from '@lucide/lab';
|
||||
*
|
||||
* provideLucideIcons(
|
||||
* LucideCircle,
|
||||
* ...lucideLegacyIconMap({ UserRoundX, burger }),
|
||||
* ),
|
||||
* ```
|
||||
*/
|
||||
export function lucideLegacyIconMap(
|
||||
icons: Record<string, LucideIconData['node']>,
|
||||
): LucideIconData[] {
|
||||
return Object.entries(icons).map(([pascalName, node]) => {
|
||||
const name: string = pascalName.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
return {
|
||||
name,
|
||||
node,
|
||||
aliases: [pascalName],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as icons from './icons/lucide-angular';
|
||||
|
||||
export * from './lucide-config';
|
||||
export * from './lucide-icon';
|
||||
export * from './lucide-dynamic-icon';
|
||||
export * from './lucide-icons';
|
||||
export * from './types';
|
||||
export * from './icons/lucide-angular';
|
||||
|
||||
@@ -2,44 +2,57 @@ import { Signal, Type } from '@angular/core';
|
||||
|
||||
type HtmlAttributes = { [key: string]: string | number };
|
||||
export type LucideIconNode = readonly [string, HtmlAttributes];
|
||||
export type LucideIconData = readonly LucideIconNode[];
|
||||
export type LucideIcons = { [key: string]: LucideIconData };
|
||||
|
||||
/**
|
||||
* Represents a Lucide icon component that has `iconName` and `iconData` signals inherited from `LucideIconBase` and respective static members accessible without instantiating the component.
|
||||
* A Lucide icon object that fully describes an icon to be displayed.
|
||||
*/
|
||||
export type LucideIconComponentType = Type<{
|
||||
export type LucideIconData = {
|
||||
name: string;
|
||||
node: LucideIconNode[];
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Input signal map of Lucide icon components.
|
||||
*/
|
||||
interface LucideIconProps {
|
||||
title: Signal<Nullable<string>>;
|
||||
size: Signal<Nullable<number>>;
|
||||
color: Signal<Nullable<string>>;
|
||||
strokeWidth: Signal<Nullable<number>>;
|
||||
absoluteStrokeWidth: Signal<Nullable<boolean>>;
|
||||
}> & {
|
||||
iconName: string;
|
||||
iconData: LucideIconData;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Lucide icon component type that has `iconName` and `iconData` signals inherited from `LucideIconBase` and respective static members accessible without instantiating the component.
|
||||
*/
|
||||
export interface LucideIcon extends Type<LucideIconProps> {
|
||||
icon: LucideIconData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for {@link LucideIconData}
|
||||
*/
|
||||
export function isLucideIconData(icon: unknown): icon is LucideIconData {
|
||||
return Array.isArray(icon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for {@link LucideIconComponentType}
|
||||
*/
|
||||
export function isLucideIconComponent(icon: unknown): icon is LucideIconComponentType {
|
||||
return (
|
||||
icon instanceof Type &&
|
||||
'iconData' in icon &&
|
||||
Array.isArray(icon.iconData) &&
|
||||
'iconName' in icon &&
|
||||
typeof icon.iconName === 'string'
|
||||
!!icon &&
|
||||
typeof icon === 'object' &&
|
||||
'name' in icon &&
|
||||
typeof icon.name === 'string' &&
|
||||
'node' in icon &&
|
||||
Array.isArray(icon.node)
|
||||
);
|
||||
}
|
||||
|
||||
export type LucideIconInput = LucideIconComponentType | LucideIconData | string;
|
||||
/**
|
||||
* Type guard for {@link LucideIcon}
|
||||
*/
|
||||
export function isLucideIconComponent(icon: unknown): icon is LucideIcon {
|
||||
return icon instanceof Type && 'icon' in icon && isLucideIconData(icon.icon);
|
||||
}
|
||||
|
||||
export type LucideIconInput = LucideIcon | LucideIconData | string;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function formatFixed(number: number, decimals = 3): string {
|
||||
return parseFloat(number.toFixed(decimals)).toString(10);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const toKebabCase = (name: string) =>
|
||||
name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
Reference in New Issue
Block a user