mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-14 13:58:21 +00:00
Merge branch 'main' into package/extract-build-logic
This commit is contained in:
@@ -22,7 +22,12 @@ describe('LucideDynamicIcon', () => {
|
||||
let component: LucideDynamicIcon;
|
||||
let fixture: ComponentFixture<LucideDynamicIcon>;
|
||||
let icon: WritableSignal<LucideIconInput | null | undefined>;
|
||||
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
|
||||
const expectSvgClasses = (classes: string[]) => {
|
||||
for (const cssClass of classes) {
|
||||
expect(fixture.nativeElement.classList.contains(cssClass)).toBe(true);
|
||||
}
|
||||
};
|
||||
const getRenderedChildren = () => Array.from(fixture.nativeElement.children) as Element[];
|
||||
const testIcon: LucideIconData = {
|
||||
name: 'demo',
|
||||
node: [['polyline', { points: '1 1 22 22' }]],
|
||||
@@ -30,11 +35,23 @@ describe('LucideDynamicIcon', () => {
|
||||
const testIcon2: LucideIconData = {
|
||||
name: 'demo-other',
|
||||
node: [
|
||||
['circle', { cx: 12, cy: 12, r: 8 }],
|
||||
['circle', { cx: 12, cy: 12, r: 8, fill: 'currentColor' }],
|
||||
['polyline', { points: '1 1 22 22' }],
|
||||
],
|
||||
aliases: ['demo-2'],
|
||||
};
|
||||
const supportedShapesIcon: LucideIconData = {
|
||||
name: 'supported-shapes',
|
||||
node: [
|
||||
['path', { d: 'm1 1 2 2', fill: 'currentColor', key: 'path-key' }],
|
||||
['line', { x1: 1, x2: 2, y1: 3, y2: 4, key: 'line-key' }],
|
||||
['polygon', { points: '1 1 2 2 3 1', key: 'polygon-key' }],
|
||||
['polyline', { points: '1 1 2 2 3 1', key: 'polyline-key' }],
|
||||
['circle', { cx: 12, cy: 12, r: 8, fill: 'currentColor', key: 'circle-key' }],
|
||||
['ellipse', { cx: 12, cy: 12, rx: 8, ry: 4, key: 'ellipse-key' }],
|
||||
['rect', { x: 1, y: 2, width: 3, height: 4, rx: 5, ry: 6, key: 'rect-key' }],
|
||||
],
|
||||
};
|
||||
function createComponent() {
|
||||
return TestBed.createComponent(LucideDynamicIcon, {
|
||||
inferTagName: true,
|
||||
@@ -58,15 +75,33 @@ describe('LucideDynamicIcon', () => {
|
||||
it('should render children', () => {
|
||||
icon.set(testIcon2);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toBe(
|
||||
'<!--container--><circle cx="12" cy="12" r="8"></circle><polyline points="1 1 22 22"></polyline><!--ng-container-->',
|
||||
const children = getRenderedChildren();
|
||||
expect(children.map((child) => child.tagName.toLowerCase())).toEqual(['circle', 'polyline']);
|
||||
expect(children[0].outerHTML).toBe(
|
||||
'<circle cx="12" cy="12" r="8" fill="currentColor"></circle>',
|
||||
);
|
||||
expect(children[1].outerHTML).toBe('<polyline points="1 1 22 22"></polyline>');
|
||||
});
|
||||
|
||||
it('should render supported SVG shapes and attributes', () => {
|
||||
icon.set(supportedShapesIcon);
|
||||
fixture.detectChanges();
|
||||
const children = getRenderedChildren();
|
||||
expect(children.map((child) => child.outerHTML)).toEqual([
|
||||
'<path d="m1 1 2 2" fill="currentColor"></path>',
|
||||
'<line x1="1" x2="2" y1="3" y2="4"></line>',
|
||||
'<polygon points="1 1 2 2 3 1"></polygon>',
|
||||
'<polyline points="1 1 2 2 3 1"></polyline>',
|
||||
'<circle cx="12" cy="12" r="8" fill="currentColor"></circle>',
|
||||
'<ellipse cx="12" cy="12" rx="8" ry="4"></ellipse>',
|
||||
'<rect x="1" y="2" width="3" height="4" rx="5" ry="6"></rect>',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove children on change', () => {
|
||||
icon.set(null);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toBe('<!--container--><!--ng-container-->');
|
||||
expect(getRenderedChildren()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('iconInput', () => {
|
||||
@@ -74,9 +109,9 @@ describe('LucideDynamicIcon', () => {
|
||||
icon.set(testIcon);
|
||||
fixture.detectChanges();
|
||||
expect(component['icon']()).toBe(testIcon);
|
||||
expect(fixture.nativeElement.innerHTML).toBe(
|
||||
'<!--container--><polyline points="1 1 22 22"></polyline><!--ng-container-->',
|
||||
);
|
||||
expect(getRenderedChildren().map((child) => child.outerHTML)).toEqual([
|
||||
'<polyline points="1 1 22 22"></polyline>',
|
||||
]);
|
||||
});
|
||||
it('should support LucideIcon input', () => {
|
||||
icon.set(LucideActivity);
|
||||
@@ -97,23 +132,24 @@ describe('LucideDynamicIcon', () => {
|
||||
describe('class', () => {
|
||||
it('should add all classes', () => {
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-demo');
|
||||
expectSvgClasses(['lucide', 'lucide-demo']);
|
||||
});
|
||||
it('should add backwards compatible classes from aliases', () => {
|
||||
icon.set(testIcon2);
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-demo-other lucide-demo-2');
|
||||
expectSvgClasses(['lucide', 'lucide-demo-other', 'lucide-demo-2']);
|
||||
});
|
||||
it('should add class icon if available', () => {
|
||||
icon.set(LucideActivity);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-activity');
|
||||
expectSvgClasses(['lucide', 'lucide-activity']);
|
||||
});
|
||||
it('should remove class on change', () => {
|
||||
icon.set(null);
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide');
|
||||
expectSvgClasses(['lucide']);
|
||||
expect(fixture.nativeElement.classList.contains('lucide-demo')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { ApplicationRef, Component, destroyPlatform, Provider, Type } from '@angular/core';
|
||||
import { bootstrapApplication, provideClientHydration } from '@angular/platform-browser';
|
||||
import { provideServerRendering, renderApplication } from '@angular/platform-server';
|
||||
import { LucideDynamicIcon } from './lucide-dynamic-icon';
|
||||
import { provideLucideIcons } from './lucide-icons';
|
||||
import { LucideBadgeAlert } from './icons/badge-alert';
|
||||
|
||||
@Component({
|
||||
// eslint-disable-next-line @angular-eslint/component-selector
|
||||
selector: 'lucide-static-hydration-root',
|
||||
template: `<svg lucideBadgeAlert [nonScalingStroke]="true"></svg>`,
|
||||
imports: [LucideBadgeAlert],
|
||||
})
|
||||
class StaticHydrationRootComponent {}
|
||||
|
||||
@Component({
|
||||
// eslint-disable-next-line @angular-eslint/component-selector
|
||||
selector: 'lucide-dynamic-hydration-root',
|
||||
template: `<svg [lucideIcon]="'badge-alert'"></svg>`,
|
||||
imports: [LucideDynamicIcon],
|
||||
})
|
||||
class DynamicHydrationRootComponent {}
|
||||
|
||||
describe('Lucide hydration', () => {
|
||||
let appRef: ApplicationRef | undefined;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
appRef?.destroy();
|
||||
appRef = undefined;
|
||||
document.body.innerHTML = '';
|
||||
delete (globalThis as { ngServerMode?: boolean }).ngServerMode;
|
||||
destroyPlatform();
|
||||
});
|
||||
|
||||
async function renderAndHydrate(
|
||||
rootComponent: Type<unknown>,
|
||||
selector: string,
|
||||
providers: Provider[] = [],
|
||||
) {
|
||||
delete (globalThis as { ngServerMode?: boolean }).ngServerMode;
|
||||
const appConfig = {
|
||||
providers: [provideClientHydration(), ...providers],
|
||||
};
|
||||
const serverAppConfig = {
|
||||
providers: [provideServerRendering(), provideClientHydration(), ...providers],
|
||||
};
|
||||
const html = await renderApplication(
|
||||
(context) => bootstrapApplication(rootComponent, serverAppConfig, context),
|
||||
{
|
||||
document: `<!doctype html><html><head></head><body><${selector}></${selector}></body></html>`,
|
||||
url: 'http://localhost/',
|
||||
allowedHosts: ['localhost'],
|
||||
},
|
||||
);
|
||||
(globalThis as { ngServerMode?: boolean }).ngServerMode = false;
|
||||
const serverDocument = new DOMParser().parseFromString(html, 'text/html');
|
||||
expectBadgeAlertShapeNodes(serverDocument.querySelector('svg') as SVGSVGElement);
|
||||
document.head.innerHTML = serverDocument.head.innerHTML;
|
||||
document.body.innerHTML = serverDocument.body.innerHTML;
|
||||
|
||||
appRef = await bootstrapApplication(rootComponent, appConfig);
|
||||
await appRef.whenStable();
|
||||
|
||||
const hasMissingHydrationWarning = warnSpy.mock.calls.some((call: unknown[]) =>
|
||||
String(call[0]).includes('NG0505'),
|
||||
);
|
||||
expect(hasMissingHydrationWarning).toBe(false);
|
||||
|
||||
return document.querySelector('svg') as SVGSVGElement;
|
||||
}
|
||||
|
||||
function expectBadgeAlertShapeNodes(svg: SVGSVGElement) {
|
||||
expect(Array.from(svg.children).map((child) => child.tagName.toLowerCase())).toEqual([
|
||||
'path',
|
||||
'line',
|
||||
'line',
|
||||
]);
|
||||
}
|
||||
|
||||
it('should hydrate static icon children without duplicating SSR nodes', async () => {
|
||||
const svg = await renderAndHydrate(
|
||||
StaticHydrationRootComponent,
|
||||
'lucide-static-hydration-root',
|
||||
);
|
||||
|
||||
expectBadgeAlertShapeNodes(svg);
|
||||
for (const child of Array.from(svg.children)) {
|
||||
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
}
|
||||
});
|
||||
|
||||
it('should hydrate dynamic icon children without duplicating SSR nodes', async () => {
|
||||
const svg = await renderAndHydrate(
|
||||
DynamicHydrationRootComponent,
|
||||
'lucide-dynamic-hydration-root',
|
||||
[provideLucideIcons(LucideBadgeAlert)],
|
||||
);
|
||||
|
||||
expectBadgeAlertShapeNodes(svg);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,12 @@ export class LucideCircleCheck extends LucideIconBase {
|
||||
protected override readonly icon = signal(LucideCircleCheck.icon);
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `<svg lucideCircleCheck class="custom-icon"></svg>`,
|
||||
imports: [LucideCircleCheck],
|
||||
})
|
||||
class ClassHostComponent {}
|
||||
|
||||
describe('LucideIconBase', () => {
|
||||
let component: LucideCircleCheck;
|
||||
let fixture: ComponentFixture<LucideCircleCheck>;
|
||||
@@ -43,6 +49,12 @@ describe('LucideIconBase', () => {
|
||||
let absoluteStrokeWidth: WritableSignal<boolean | undefined>;
|
||||
let nonScalingStroke: WritableSignal<boolean | undefined>;
|
||||
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
|
||||
const expectSvgClasses = (classes: string[], svg: SVGElement = fixture.nativeElement) => {
|
||||
for (const cssClass of classes) {
|
||||
expect(svg.classList.contains(cssClass)).toBe(true);
|
||||
}
|
||||
};
|
||||
const getRenderedChildren = () => Array.from(fixture.nativeElement.children) as Element[];
|
||||
function createComponent() {
|
||||
return TestBed.createComponent(LucideCircleCheck, {
|
||||
inferTagName: true,
|
||||
@@ -74,15 +86,25 @@ describe('LucideIconBase', () => {
|
||||
|
||||
it('should render children', () => {
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toBe(
|
||||
'<!--container--><circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path><!--ng-container-->',
|
||||
);
|
||||
const children = getRenderedChildren();
|
||||
expect(children.map((child) => child.tagName.toLowerCase())).toEqual(['circle', 'path']);
|
||||
expect(children[0].outerHTML).toBe('<circle cx="12" cy="12" r="10"></circle>');
|
||||
expect(children[1].outerHTML).toBe('<path d="m9 12 2 2 4-4"></path>');
|
||||
});
|
||||
|
||||
describe('class', () => {
|
||||
it('should add all classes', () => {
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('class')).toBe('lucide lucide-circle-check lucide-check-circle-2');
|
||||
expectSvgClasses(['lucide', 'lucide-circle-check', 'lucide-check-circle-2']);
|
||||
});
|
||||
it('should preserve user classes', () => {
|
||||
const hostFixture = TestBed.createComponent(ClassHostComponent);
|
||||
hostFixture.detectChanges();
|
||||
const svg = hostFixture.nativeElement.querySelector('svg');
|
||||
expectSvgClasses(
|
||||
['custom-icon', 'lucide', 'lucide-circle-check', 'lucide-check-circle-2'],
|
||||
svg,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,13 +188,15 @@ describe('LucideIconBase', () => {
|
||||
});
|
||||
it('should not set vector-effect on children', () => {
|
||||
nonScalingStroke.set(false);
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
fixture.detectChanges();
|
||||
for (const child of getRenderedChildren()) {
|
||||
expect(child.getAttribute('vector-effect')).toBeNull();
|
||||
}
|
||||
});
|
||||
it('should set vector-effect on children', () => {
|
||||
nonScalingStroke.set(true);
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
fixture.detectChanges();
|
||||
for (const child of getRenderedChildren()) {
|
||||
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
}
|
||||
});
|
||||
@@ -207,6 +231,7 @@ describe('LucideIconBase', () => {
|
||||
color: 'red',
|
||||
strokeWidth: 1,
|
||||
size: 12,
|
||||
nonScalingStroke: true,
|
||||
absoluteStrokeWidth: true,
|
||||
}),
|
||||
],
|
||||
@@ -252,15 +277,13 @@ describe('LucideIconBase', () => {
|
||||
});
|
||||
describe('absoluteStrokeWidth', () => {
|
||||
it('should use absoluteStrokeWidth from config', () => {
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
}
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('stroke-width')).toBe('2');
|
||||
});
|
||||
it('should override absoluteStrokeWidth', () => {
|
||||
absoluteStrokeWidth.set(false);
|
||||
for (const child of fixture.nativeElement.children) {
|
||||
expect(child.getAttribute('vector-effect')).toBeNull();
|
||||
}
|
||||
fixture.detectChanges();
|
||||
expect(getSvgAttribute('stroke-width')).toBe('1');
|
||||
});
|
||||
});
|
||||
describe('nonScalingStroke', () => {
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
Renderer2,
|
||||
Signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Component, computed, inject, input, Signal } from '@angular/core';
|
||||
import { LUCIDE_CONFIG } from './lucide-config';
|
||||
import { LucideIconData, LucideIconNode, Nullable } from './types';
|
||||
import defaultAttributes from './default-attributes';
|
||||
@@ -36,9 +26,6 @@ import buildLucideIconNode from './utils/buildLucideIconNode';
|
||||
export abstract class LucideIconBase {
|
||||
protected abstract readonly icon: Signal<Nullable<LucideIconData>>;
|
||||
protected readonly iconConfig = inject(LUCIDE_CONFIG);
|
||||
protected readonly elRef = inject(ElementRef);
|
||||
protected readonly renderer = inject(Renderer2);
|
||||
protected readonly contentRef = viewChild.required<ElementRef>('contentRef');
|
||||
/**
|
||||
* An optional accessible label for the icon.
|
||||
* - If provided, it will add the title as an [`<svg:title>` element](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/title).
|
||||
@@ -52,6 +39,10 @@ export abstract class LucideIconBase {
|
||||
* - If your icon is functional (e.g. used in place of a label), feel free to use this property.
|
||||
*/
|
||||
readonly title = input<Nullable<string>>();
|
||||
/**
|
||||
* Optional CSS classes for the icon.
|
||||
*/
|
||||
readonly class = input<Nullable<string>>();
|
||||
/**
|
||||
* Width and height.
|
||||
* @default 24
|
||||
@@ -109,6 +100,7 @@ export abstract class LucideIconBase {
|
||||
strokeWidth: this.strokeWidth(),
|
||||
absoluteStrokeWidth: this.absoluteStrokeWidth(),
|
||||
nonScalingStroke: this.nonScalingStroke(),
|
||||
className: this.class() ?? undefined,
|
||||
hasA11yProp: this.title() != null,
|
||||
})
|
||||
: null;
|
||||
@@ -124,7 +116,7 @@ export abstract class LucideIconBase {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
protected readonly computedIconNode = computed(() => this.computedIcon()?.[2]);
|
||||
protected readonly computedIconNode = computed(() => this.computedIcon()?.[2] ?? []);
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -174,33 +166,4 @@ export abstract class LucideIconBase {
|
||||
protected readonly computedClass = computed(
|
||||
() => this.computedAttributes()?.['class'] ?? 'lucide',
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
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 element = this.renderer.createElement(name, 'http://www.w3.org/2000/svg');
|
||||
|
||||
Object.entries(attrs).forEach(([name, value]) =>
|
||||
this.renderer.setAttribute(
|
||||
element,
|
||||
name,
|
||||
typeof value === 'number' ? value.toString(10) : value,
|
||||
),
|
||||
);
|
||||
this.renderer.insertBefore(this.elRef.nativeElement, element, refChild);
|
||||
return element;
|
||||
});
|
||||
onCleanup(() => {
|
||||
elements.forEach((element) =>
|
||||
this.renderer.removeChild(this.elRef.nativeElement, element),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,66 @@ export const lucideIconTemplate = `@if (title(); as titleValue) {
|
||||
<title>{{ titleValue }}</title>
|
||||
}
|
||||
<ng-content select="title"></ng-content>
|
||||
<ng-container #contentRef></ng-container>
|
||||
@for (child of computedIconNode(); track child[1]['key'] ?? $index) {
|
||||
@let attrs = child[1];
|
||||
@switch (child[0]) {
|
||||
@case ('path') {
|
||||
<svg:path
|
||||
[attr.d]="attrs['d']"
|
||||
[attr.fill]="attrs['fill']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('line') {
|
||||
<svg:line
|
||||
[attr.x1]="attrs['x1']"
|
||||
[attr.x2]="attrs['x2']"
|
||||
[attr.y1]="attrs['y1']"
|
||||
[attr.y2]="attrs['y2']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('polygon') {
|
||||
<svg:polygon
|
||||
[attr.points]="attrs['points']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('polyline') {
|
||||
<svg:polyline
|
||||
[attr.points]="attrs['points']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('circle') {
|
||||
<svg:circle
|
||||
[attr.cx]="attrs['cx']"
|
||||
[attr.cy]="attrs['cy']"
|
||||
[attr.r]="attrs['r']"
|
||||
[attr.fill]="attrs['fill']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('ellipse') {
|
||||
<svg:ellipse
|
||||
[attr.cx]="attrs['cx']"
|
||||
[attr.cy]="attrs['cy']"
|
||||
[attr.rx]="attrs['rx']"
|
||||
[attr.ry]="attrs['ry']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
@case ('rect') {
|
||||
<svg:rect
|
||||
[attr.x]="attrs['x']"
|
||||
[attr.y]="attrs['y']"
|
||||
[attr.width]="attrs['width']"
|
||||
[attr.height]="attrs['height']"
|
||||
[attr.rx]="attrs['rx']"
|
||||
[attr.ry]="attrs['ry']"
|
||||
[attr.vector-effect]="attrs['vector-effect']"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
<ng-content />`;
|
||||
|
||||
Reference in New Issue
Block a user