diff --git a/packages/lucide-angular/package.json b/packages/lucide-angular/package.json index 54420b8fa..3528e7d4d 100644 --- a/packages/lucide-angular/package.json +++ b/packages/lucide-angular/package.json @@ -1,6 +1,6 @@ { - "name": "@lucide/angular", - "description": "A Lucide icon library package for Angular applications", + "name": "lucide-angular", + "description": "A Lucide icon library package for Angular applications.", "version": "0.0.1", "author": "SMAH1", "license": "ISC", @@ -38,19 +38,19 @@ "version": "pnpm version --git-tag-version=false" }, "devDependencies": { - "@angular-devkit/build-angular": "~17.3.14", - "@angular-eslint/builder": "~17.5.3", - "@angular-eslint/eslint-plugin": "~17.5.3", - "@angular-eslint/eslint-plugin-template": "~17.5.3", - "@angular-eslint/schematics": "~17.5.3", - "@angular-eslint/template-parser": "~17.5.3", - "@angular/cli": "~17.3.14", - "@angular/common": "~17.3.12", - "@angular/compiler": "~17.3.12", - "@angular/compiler-cli": "~17.3.12", - "@angular/core": "~17.3.12", - "@angular/platform-browser": "~17.3.12", - "@angular/platform-browser-dynamic": "~17.3.12", + "@angular-devkit/build-angular": "~13.3.11", + "@angular-eslint/builder": "~13.0.0", + "@angular-eslint/eslint-plugin": "~13.0.0", + "@angular-eslint/eslint-plugin-template": "~13.0.0", + "@angular-eslint/schematics": "~13.0.0", + "@angular-eslint/template-parser": "~13.0.0", + "@angular/cli": "~13.3.11", + "@angular/common": "~13.3.0", + "@angular/compiler": "~13.3.0", + "@angular/compiler-cli": "~13.3.0", + "@angular/core": "~13.3.0", + "@angular/platform-browser": "~13.3.0", + "@angular/platform-browser-dynamic": "~13.3.0", "@lucide/build-icons": "workspace:*", "@types/jasmine": "~3.10.0", "@types/node": "^12.11.1", @@ -65,12 +65,12 @@ "karma-coverage": "~2.1.0", "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "~1.7.0", - "ng-packagr": "^17.3.0", + "ng-packagr": "^13.3.0", "prettier": "^2.8.4", - "rxjs": "~6.5.3", + "rxjs": "~7.5.0", "ts-node": "~10.9.1", "tslib": "^2.3.0", - "typescript": "~5.4.5", + "typescript": "~4.6.2", "zone.js": "~0.11.4" }, "peerDependencies": { diff --git a/packages/lucide-angular/scripts/exportTemplate.mts b/packages/lucide-angular/scripts/exportTemplate.mts index 76c6ab8d8..6376483cf 100644 --- a/packages/lucide-angular/scripts/exportTemplate.mts +++ b/packages/lucide-angular/scripts/exportTemplate.mts @@ -8,65 +8,26 @@ export default defineExportTemplate(async ({ getSvg, deprecated, deprecationReason, - aliases = [], - toPascalCase, }) => { const svgContents = await getSvg(); const svgBase64 = base64SVG(svgContents); - 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)}]`; - if (!selectors.includes(aliasSelector)) { - selectors.push(aliasSelector); - } - if (aliasComponentName !== angularComponentName && !aliasComponentNames.includes(aliasComponentName)) { - aliasComponentNames.push(aliasComponentName); - } - } return `\ import { LucideIconData } from './types'; -import { LucideIcon } from '../lib/lucide-icon.component'; -import { Component } from '@angular/core'; /** * @component @name ${componentName} * @description Lucide SVG icon component, renders SVG Element with children. * * @preview ![img](data:image/svg+xml;base64,${svgBase64}) - https://lucide.dev/icons/${iconName} - * @see https://lucide.dev/guide/packages/lucide-angular - Documentation + * @see https://lucide.dev/guide/packages/lucide-vue-next - Documentation * * @param {Object} props - Lucide icons props and any valid SVG attribute + * @returns {FunctionalComponent} Vue component * ${deprecated ? `@deprecated ${deprecationReason}` : ''} */ -@Component({ - selector: '${selectors.join(', ')}', - template: '', - standalone: true, -}) -export class ${angularComponentName} extends LucideIcon { - static iconData: LucideIconData = ${JSON.stringify(children)}; - static iconName = '${iconName}'; - override get icon() { - return ${angularComponentName}.iconData; - } - override get name() { - return ${angularComponentName}.iconName; - } -} +const ${componentName}: LucideIconData = ${JSON.stringify(children)}; //eslint-disable-line no-shadow-restricted-names -${aliasComponentNames.map(([aliasComponentName]) => { - return ` -/** - * @deprecated - * @see ${angularComponentName} - */ -export const ${aliasComponentName} = ${angularComponentName}; -`; - }).join(`\n\n`)} +export default ${componentName}; `; }); diff --git a/packages/lucide-angular/src/aliases/index.ts b/packages/lucide-angular/src/aliases/index.ts new file mode 100644 index 000000000..bbe66d0a2 --- /dev/null +++ b/packages/lucide-angular/src/aliases/index.ts @@ -0,0 +1,3 @@ +export * from './aliases'; +export * from './prefixed'; +export * from './suffixed'; diff --git a/packages/lucide-angular/src/icons/package.json b/packages/lucide-angular/src/icons/package.json new file mode 100644 index 000000000..23510812c --- /dev/null +++ b/packages/lucide-angular/src/icons/package.json @@ -0,0 +1,8 @@ +{ + "ngPackage": { + "dest": "dist", + "lib": { + "entryFile": "../public-api.ts" + } + } +} diff --git a/packages/lucide-angular/src/lib/icons.provider.ts b/packages/lucide-angular/src/lib/icons.provider.ts new file mode 100644 index 000000000..ef768fa2a --- /dev/null +++ b/packages/lucide-angular/src/lib/icons.provider.ts @@ -0,0 +1,4 @@ +/** @deprecated Use the injection token LUCIDE_ICONS instead. Will be removed in v1.0. */ +export class Icons { + constructor(private icons: object) {} +} diff --git a/packages/lucide-angular/src/lib/lucide-angular.component.spec.ts b/packages/lucide-angular/src/lib/lucide-angular.component.spec.ts new file mode 100644 index 000000000..33578a41d --- /dev/null +++ b/packages/lucide-angular/src/lib/lucide-angular.component.spec.ts @@ -0,0 +1,103 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LucideAngularModule } from './lucide-angular.module'; +import { formatFixed, LucideAngularComponent } from './lucide-angular.component'; +import defaultAttributes from '../icons/constants/default-attributes'; +import { LucideIcons } from '../icons/types'; + +describe('LucideAngularComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + const getSvgAttribute = (attr: string) => + testHostFixture.nativeElement.querySelector('svg').getAttribute(attr); + const testIcons: LucideIcons = { + Demo: [['polyline', { points: '1 1 22 22' }]], + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LucideAngularComponent, TestHostComponent], + imports: [LucideAngularModule.pick(testIcons)], + }).compileComponents(); + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + }); + + it('should create', () => { + testHostFixture.detectChanges(); + expect(testHostComponent).toBeTruthy(); + }); + + it('should add all classes', () => { + testHostFixture.detectChanges(); + + expect(getSvgAttribute('class')).toBe('lucide lucide-demo my-icon'); + }); + + it('should set color', () => { + const color = 'red'; + testHostComponent.setColor(color); + testHostFixture.detectChanges(); + expect(getSvgAttribute('stroke')).toBe(color); + }); + + it('should set size', () => { + const size = 12; + testHostComponent.setSize(size); + testHostFixture.detectChanges(); + expect(getSvgAttribute('width')).toBe(size.toString(10)); + }); + + it('should set stroke width', () => { + const strokeWidth = 1.41; + testHostComponent.setStrokeWidth(strokeWidth); + testHostFixture.detectChanges(); + expect(getSvgAttribute('stroke-width')).toBe(strokeWidth.toString(10)); + }); + + it('should adjust stroke width', () => { + const strokeWidth = 2; + const size = 12; + testHostComponent.setStrokeWidth(strokeWidth); + testHostComponent.setSize(12); + testHostComponent.setAbsoluteStrokeWidth(true); + testHostFixture.detectChanges(); + expect(getSvgAttribute('stroke-width')).toBe( + formatFixed(strokeWidth / (size / defaultAttributes.height)), + ); + }); + + @Component({ + selector: 'lucide-spec-host-component', + template: ` `, + }) + class TestHostComponent { + color?: string; + size?: number; + strokeWidth?: number; + absoluteStrokeWidth = true; + + setColor(color: string): void { + this.color = color; + } + + setSize(size: number): void { + this.size = size; + } + + setStrokeWidth(strokeWidth: number): void { + this.strokeWidth = strokeWidth; + } + + setAbsoluteStrokeWidth(absoluteStrokeWidth: boolean): void { + this.absoluteStrokeWidth = absoluteStrokeWidth; + } + } +}); diff --git a/packages/lucide-angular/src/lib/lucide-angular.component.ts b/packages/lucide-angular/src/lib/lucide-angular.component.ts new file mode 100644 index 000000000..931239cba --- /dev/null +++ b/packages/lucide-angular/src/lib/lucide-angular.component.ts @@ -0,0 +1,202 @@ +import { + ChangeDetectorRef, + Component, + ElementRef, + Inject, + Input, + OnChanges, + Renderer2, + SimpleChange, +} from '@angular/core'; +import { LucideIconData } from '../icons/types'; +import defaultAttributes from '../icons/constants/default-attributes'; +import { LUCIDE_ICONS, LucideIconProviderInterface } from './lucide-icon.provider'; +import { LucideIconConfig } from './lucide-icon.config'; + +interface TypedChange extends SimpleChange { + previousValue: T; + currentValue: T; +} + +type SvgAttributes = { [key: string]: string | number }; + +type LucideAngularComponentChanges = { + name?: TypedChange; + img?: TypedChange; + color?: TypedChange; + size?: TypedChange; + strokeWidth?: TypedChange; + absoluteStrokeWidth?: TypedChange; + class: TypedChange; +}; + +export function formatFixed(number: number, decimals = 3): string { + return parseFloat(number.toFixed(decimals)).toString(10); +} + +@Component({ + selector: 'lucide-angular, lucide-icon, i-lucide, span-lucide', + template: '', +}) +export class LucideAngularComponent implements OnChanges { + @Input() class?: string; + @Input() name?: string | LucideIconData; + @Input() img?: LucideIconData; + @Input() color?: string; + @Input() absoluteStrokeWidth = false; + defaultSize: number; + + constructor( + @Inject(ElementRef) private elem: ElementRef, + @Inject(Renderer2) private renderer: Renderer2, + @Inject(ChangeDetectorRef) private changeDetector: ChangeDetectorRef, + @Inject(LUCIDE_ICONS) private iconProviders: LucideIconProviderInterface[], + @Inject(LucideIconConfig) private iconConfig: LucideIconConfig, + ) { + this.defaultSize = defaultAttributes.height; + } + + _size?: number; + + get size(): number { + return this._size ?? this.iconConfig.size; + } + + @Input() set size(value: string | number | undefined) { + if (value) { + this._size = this.parseNumber(value); + } else { + delete this._size; + } + } + + _strokeWidth?: number; + + get strokeWidth(): number { + return this._strokeWidth ?? this.iconConfig.strokeWidth; + } + + @Input() set strokeWidth(value: string | number | undefined) { + if (value) { + this._strokeWidth = this.parseNumber(value); + } else { + delete this._strokeWidth; + } + } + + ngOnChanges(changes: LucideAngularComponentChanges): void { + if ( + changes.name || + changes.img || + changes.color || + changes.size || + changes.absoluteStrokeWidth || + changes.strokeWidth || + changes.class + ) { + this.color = this.color ?? this.iconConfig.color; + this.size = this.parseNumber(this.size ?? this.iconConfig.size); + this.strokeWidth = this.parseNumber(this.strokeWidth ?? this.iconConfig.strokeWidth); + this.absoluteStrokeWidth = this.absoluteStrokeWidth ?? this.iconConfig.absoluteStrokeWidth; + const nameOrIcon = this.img ?? this.name; + if (typeof nameOrIcon === 'string') { + const icoOfName = this.getIcon(this.toPascalCase(nameOrIcon)); + if (icoOfName) { + this.replaceElement(icoOfName); + } else { + throw new Error( + `The "${nameOrIcon}" icon has not been provided by any available icon providers.`, + ); + } + } else if (Array.isArray(nameOrIcon)) { + this.replaceElement(nameOrIcon); + } else { + throw new Error(`No icon name or image has been provided.`); + } + } + + this.changeDetector.markForCheck(); + } + + replaceElement(img: LucideIconData): void { + const attributes = { + ...defaultAttributes, + width: this.size, + height: this.size, + stroke: this.color ?? this.iconConfig.color, + 'stroke-width': this.absoluteStrokeWidth + ? formatFixed(this.strokeWidth / (this.size / this.defaultSize)) + : this.strokeWidth.toString(10), + }; + const icoElement = this.createElement(['svg', attributes, img]); + icoElement.classList.add('lucide'); + if (typeof this.name === 'string') { + icoElement.classList.add(`lucide-${this.name.replace('_', '-')}`); + } + if (this.class) { + icoElement.classList.add( + ...this.class + .split(/ /) + .map((a) => a.trim()) + .filter((a) => a.length > 0), + ); + } + const childElements = this.elem.nativeElement.childNodes; + for (const child of childElements) { + this.renderer.removeChild(this.elem.nativeElement, child); + } + this.renderer.appendChild(this.elem.nativeElement, icoElement); + } + + toPascalCase(str: string): string { + return str.replace( + /(\w)([a-z0-9]*)(_|-|\s*)/g, + (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase(), + ); + } + + private parseNumber(value: string | number): number { + if (typeof value === 'string') { + const parsedValue = parseInt(value, 10); + if (isNaN(parsedValue)) { + throw new Error(`${value} is not numeric.`); + } + return parsedValue; + } + return value; + } + + private getIcon(name: string): LucideIconData | null { + for (const iconProvider of Array.isArray(this.iconProviders) + ? this.iconProviders + : [this.iconProviders]) { + if (iconProvider.hasIcon(name)) { + return iconProvider.getIcon(name); + } + } + return null; + } + + private createElement([tag, attrs, children = []]: readonly [ + string, + SvgAttributes, + LucideIconData?, + ]) { + const element = this.renderer.createElement(tag, 'http://www.w3.org/2000/svg'); + + Object.keys(attrs).forEach((name) => { + const attrValue: string = + typeof attrs[name] === 'string' ? (attrs[name] as string) : attrs[name].toString(10); + this.renderer.setAttribute(element, name, attrValue); + }); + + if (children.length) { + children.forEach((child) => { + const childElement = this.createElement(child); + this.renderer.appendChild(element, childElement); + }); + } + + return element; + } +} diff --git a/packages/lucide-angular/src/lib/lucide-angular.module.ts b/packages/lucide-angular/src/lib/lucide-angular.module.ts new file mode 100644 index 000000000..df41c5dea --- /dev/null +++ b/packages/lucide-angular/src/lib/lucide-angular.module.ts @@ -0,0 +1,31 @@ +import { ModuleWithProviders, NgModule, Optional } from '@angular/core'; +import { LucideAngularComponent } from './lucide-angular.component'; +import { LucideIcons } from '../icons/types'; +import { LUCIDE_ICONS, LucideIconProvider } from './lucide-icon.provider'; +import { Icons } from './icons.provider'; + +const legacyIconProviderFactory = (icons?: LucideIcons) => { + return new LucideIconProvider(icons ?? {}); +}; + +@NgModule({ + declarations: [LucideAngularComponent], + imports: [], + exports: [LucideAngularComponent], +}) +export class LucideAngularModule { + static pick(icons: LucideIcons): ModuleWithProviders { + return { + ngModule: LucideAngularModule, + providers: [ + { provide: LUCIDE_ICONS, multi: true, useValue: new LucideIconProvider(icons) }, + { + provide: LUCIDE_ICONS, + multi: true, + useFactory: legacyIconProviderFactory, + deps: [[new Optional(), Icons]], + }, + ], + }; + } +} diff --git a/packages/lucide-angular/src/lib/lucide-icon.provider.ts b/packages/lucide-angular/src/lib/lucide-icon.provider.ts new file mode 100644 index 000000000..53975cbdd --- /dev/null +++ b/packages/lucide-angular/src/lib/lucide-icon.provider.ts @@ -0,0 +1,24 @@ +import { LucideIconData, LucideIcons } from '../icons/types'; +import { InjectionToken } from '@angular/core'; + +export interface LucideIconProviderInterface { + hasIcon(name: string): boolean; + + getIcon(name: string): LucideIconData | null; +} + +export const LUCIDE_ICONS = new InjectionToken('LucideIcons', { + factory: () => new LucideIconProvider({}), +}); + +export class LucideIconProvider implements LucideIconProviderInterface { + constructor(private icons: LucideIcons) {} + + getIcon(name: string): LucideIconData | null { + return this.hasIcon(name) ? this.icons[name] : null; + } + + hasIcon(name: string): boolean { + return typeof this.icons === 'object' && name in this.icons; + } +} diff --git a/packages/lucide-angular/src/public-api.ts b/packages/lucide-angular/src/public-api.ts index 69a020e65..0cc803097 100644 --- a/packages/lucide-angular/src/public-api.ts +++ b/packages/lucide-angular/src/public-api.ts @@ -1,7 +1,10 @@ import * as icons from './icons/lucide-icons'; -export * from './lib/lucide-icon.component'; +export * from './lib/lucide-angular.component'; +export * from './lib/lucide-angular.module'; export * from './lib/lucide-icon.config'; +export * from './lib/lucide-icon.provider'; export * from './icons/lucide-icons'; export * from './icons/types'; +export * from './aliases'; export { icons };