UNPKG

angular-svg-icon

Version:

Angular 19 component and service for inlining SVGs allowing them to be easily styled with CSS.

395 lines (388 loc) 17.1 kB
import * as i0 from '@angular/core'; import { Injectable, InjectionToken, inject, Optional, SkipSelf, ElementRef, KeyValueDiffers, Renderer2, input, signal, effect, Component, ChangeDetectionStrategy, makeEnvironmentProviders, NgModule } from '@angular/core'; import { of, throwError } from 'rxjs'; import { map, tap, catchError, share } from 'rxjs/operators'; import { DOCUMENT } from '@angular/common'; import * as i1 from '@angular/common/http'; class SvgLoader { } class SvgHttpLoader extends SvgLoader { constructor(http) { super(); this.http = http; } getSvg(url) { return this.http.get(url, { responseType: 'text' }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SvgHttpLoader, deps: [{ token: i1.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SvgHttpLoader }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SvgHttpLoader, decorators: [{ type: Injectable }], ctorParameters: () => [{ type: i1.HttpClient }] }); const SERVER_URL = new InjectionToken('SERVER_URL'); class SvgIconRegistryService { constructor() { this.loader = inject(SvgLoader); this.serverUrl = inject(SERVER_URL, { optional: true }); this.document = inject(DOCUMENT); this.iconsByUrl = new Map(); this.iconsLoadingByUrl = new Map(); } /** Add a SVG to the registry by passing a name and the SVG. */ addSvg(name, data) { if (!this.iconsByUrl.has(name)) { const div = this.document.createElement('DIV'); div.innerHTML = data; const svg = div.querySelector('svg'); this.iconsByUrl.set(name, svg); } } /** Load a SVG to the registry from a URL. */ loadSvg(url, name = url) { // not sure if there should be a possibility to use name for server usage // so overriding it for now if provided // maybe should separate functionality for url and name use-cases if (this.serverUrl && url.match(/^(http(s)?):/) === null) { url = this.serverUrl + url; name = url; } if (this.iconsByUrl.has(name)) { return of(this.iconsByUrl.get(name)); } else if (this.iconsLoadingByUrl.has(name)) { return this.iconsLoadingByUrl.get(name); } const o = this.loader.getSvg(url).pipe(map(svg => { const div = this.document.createElement('DIV'); div.innerHTML = svg; return div.querySelector('svg'); }), tap(svg => { this.iconsByUrl.set(name, svg); this.iconsLoadingByUrl.delete(name); }), catchError(err => { console.error(err); return throwError(err); }), share()); this.iconsLoadingByUrl.set(name, o); return o; } /** Get loaded SVG from registry by name. (also works by url because of blended map) */ getSvgByName(name) { if (this.iconsByUrl.has(name)) { return of(this.iconsByUrl.get(name)); } else if (this.iconsLoadingByUrl.has(name)) { return this.iconsLoadingByUrl.get(name); } return throwError(`No svg with name '${name}' has been loaded`); } /** Remove a SVG from the registry by URL (or name). */ unloadSvg(url) { if (this.iconsByUrl.has(url)) { this.iconsByUrl.delete(url); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SvgIconRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SvgIconRegistryService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SvgIconRegistryService, decorators: [{ type: Injectable }] }); function SVG_ICON_REGISTRY_PROVIDER_FACTORY(parentRegistry) { return parentRegistry || new SvgIconRegistryService(); } const SVG_ICON_REGISTRY_PROVIDER = { provide: SvgIconRegistryService, deps: [[new Optional(), new SkipSelf(), SvgIconRegistryService]], useFactory: SVG_ICON_REGISTRY_PROVIDER_FACTORY }; class SvgIconHelper { constructor() { this.loaded = false; } } class SvgIconComponent { constructor() { this.element = inject(ElementRef); this.differs = inject(KeyValueDiffers); this.renderer = inject(Renderer2); this.iconReg = inject(SvgIconRegistryService); this.src = input(); this.name = input(); this.stretch = input(false); this.applyClass = input(false); this.svgClass = input(); this.klass = input(undefined, { alias: 'class' }); this.viewBox = input(); this.svgAriaLabel = input(); this.onSVGLoaded = input(); this.svg = signal(0); // Adapted from ngStyle (see: angular/packages/common/src/directives/ng_style.ts) this.svgStyle = input(); this.helper = new SvgIconHelper(); // Watch for src or name changes effect(() => { if (this.src() || this.name()) { this.destroy(); this.init(this.src(), this.name()); } }); // Watch for viewBox changes effect(() => { const viewBox = this.viewBox(); if (!this.svg()) return; this.updateViewBox(viewBox); }); // Watch for style changes effect(() => { const values = this.svgStyle() || {}; if (!this.svg()) return; this.applyChanges(this.helper.differ.diff(values)); }); // Watch for applyClass changes effect(() => { const _ = this.svg(); if (this.applyClass()) { this.setClass(this.elemSvg, null, this.klass()); } else { this.setClass(this.elemSvg, this.klass(), null); } }); // Watch for svgClass changes let previousSvgClass; effect(() => { const _ = this.svg(); this.setClass(this.elemSvg, previousSvgClass, this.svgClass()); previousSvgClass = this.svgClass(); }); // Watch for klass changes let previousKlass; effect(() => { const _ = this.svg(); const elem = this.element.nativeElement; this.setClass(elem, previousKlass, this.klass()); this.setClass(this.elemSvg, previousKlass, this.applyClass() ? this.klass() : null); previousKlass = this.klass(); }); // Watch for svgAriaLabel changes effect(() => { const _ = this.svg(); this.doAria(this.svgAriaLabel()); }); // Watch for stretch changes effect(() => { const _ = this.svg(); this.stylize(this.stretch()); }); } ngOnDestroy() { this.destroy(); } get elemSvg() { return this.element.nativeElement.firstChild; } init(src, name) { if (src && name) { const svgObs = this.iconReg.loadSvg(src, name); if (svgObs) { this.helper.icnSub = svgObs.subscribe(svg => this.initSvg(svg)); } } else if (name) { const svgObs = this.iconReg.getSvgByName(name); if (svgObs) { this.helper.icnSub = svgObs.subscribe(svg => this.initSvg(svg)); } } else if (src) { const svgObs = this.iconReg.loadSvg(src); if (svgObs) { this.helper.icnSub = svgObs.subscribe(svg => this.initSvg(svg)); } } else { this.element.nativeElement.innerHTML = ''; this.svg.set(0); } } initSvg(svg) { if (!this.helper.loaded && svg) { this.setSvg(svg); } } destroy() { this.helper.icnSub?.unsubscribe(); this.helper = new SvgIconHelper(); // initialize differ with empty object this.helper.differ = this.differs.find({}).create(); } setSvg(svg) { if (!this.helper.loaded && svg) { this.helper.svg = svg; let icon = svg.cloneNode(true); const elem = this.element.nativeElement; const onLoadCallback = this?.onSVGLoaded(); if (onLoadCallback) { icon = onLoadCallback(icon, elem); } elem.innerHTML = ''; this.renderer.appendChild(elem, icon); this.helper.loaded = true; this.copyNgContentAttribute(elem, icon); this.svg.update(x => x + 1); } } updateViewBox(viewBox) { if (viewBox) { const icon = this.elemSvg; if (viewBox === 'auto') { // Attempt to convert height & width to a viewBox. const w = icon.getAttribute('width'); const h = icon.getAttribute('height'); if (h && w) { const vb = `0 0 ${w} ${h}`; this.renderer.setAttribute(icon, 'viewBox', vb); this.renderer.removeAttribute(icon, 'width'); this.renderer.removeAttribute(icon, 'height'); } } else if (viewBox !== '') { this.renderer.setAttribute(icon, 'viewBox', viewBox); this.renderer.removeAttribute(icon, 'width'); this.renderer.removeAttribute(icon, 'height'); } } } copyNgContentAttribute(hostElem, icon) { const attributes = hostElem.attributes; const len = attributes.length; for (let i = 0; i < len; i += 1) { const attribute = attributes.item(i); if (attribute && attribute.name.startsWith('_ngcontent')) { this.setNgContentAttribute(icon, attribute.name); break; } } } setNgContentAttribute(parent, attributeName) { this.renderer.setAttribute(parent, attributeName, ''); const len = parent.childNodes.length; for (let i = 0; i < len; i += 1) { const child = parent.childNodes[i]; if (child instanceof Element) { this.setNgContentAttribute(child, attributeName); } } } stylize(stretch) { if (this.helper.svg) { const svg = this.element.nativeElement.firstChild; if (stretch === true) { this.renderer.setAttribute(svg, 'preserveAspectRatio', 'none'); } else if (stretch === false) { this.renderer.removeAttribute(svg, 'preserveAspectRatio'); } } } applyChanges(changes) { if (!changes) return; changes.forEachRemovedItem((record) => this.setStyle(record.key, null)); changes.forEachAddedItem((record) => this.setStyle(record.key, record.currentValue)); changes.forEachChangedItem((record) => this.setStyle(record.key, record.currentValue)); } setStyle(nameAndUnit, value) { const [name, unit] = nameAndUnit.split('.'); value = value !== null && unit ? `${value}${unit}` : value; const svg = this.elemSvg; if (value !== null) { this.renderer.setStyle(svg, name, value); } else { this.renderer.removeStyle(svg, name); } } setClass(target, previous, current) { if (target) { if (previous) { const klasses = (Array.isArray(previous) ? previous : previous.split(' ')).filter((klass) => klass); for (const k of klasses) { this.renderer.removeClass(target, k); } } if (current) { const klasses = (Array.isArray(current) ? current : current.split(' ')).filter((klass) => klass); for (const k of klasses) { this.renderer.addClass(target, k); } } } } doAria(label) { if (label !== undefined) { const svg = this.element.nativeElement.firstChild; // If there is not a svgAriaLabel and the SVG has an arial-label, then do not override // the SVG's aria-label. if (svg && !svg.hasAttribute('aria-label')) { if (label === '') { this.renderer.setAttribute(svg, 'aria-hidden', 'true'); this.renderer.removeAttribute(svg, 'aria-label'); } else { this.renderer.removeAttribute(svg, 'aria-hidden'); this.renderer.setAttribute(svg, 'aria-label', label); } } } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SvgIconComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.0.5", type: SvgIconComponent, isStandalone: true, selector: "svg-icon", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, stretch: { classPropertyName: "stretch", publicName: "stretch", isSignal: true, isRequired: false, transformFunction: null }, applyClass: { classPropertyName: "applyClass", publicName: "applyClass", isSignal: true, isRequired: false, transformFunction: null }, svgClass: { classPropertyName: "svgClass", publicName: "svgClass", isSignal: true, isRequired: false, transformFunction: null }, klass: { classPropertyName: "klass", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, viewBox: { classPropertyName: "viewBox", publicName: "viewBox", isSignal: true, isRequired: false, transformFunction: null }, svgAriaLabel: { classPropertyName: "svgAriaLabel", publicName: "svgAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, onSVGLoaded: { classPropertyName: "onSVGLoaded", publicName: "onSVGLoaded", isSignal: true, isRequired: false, transformFunction: null }, svgStyle: { classPropertyName: "svgStyle", publicName: "svgStyle", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: '<ng-content></ng-content>', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: SvgIconComponent, decorators: [{ type: Component, args: [{ standalone: true, selector: 'svg-icon', template: '<ng-content></ng-content>', changeDetection: ChangeDetectionStrategy.OnPush }] }], ctorParameters: () => [] }); function provideAngularSvgIcon(config = {}) { return makeEnvironmentProviders([ SVG_ICON_REGISTRY_PROVIDER, config.loader || { provide: SvgLoader, useClass: SvgHttpLoader }, ]); } class AngularSvgIconModule { static forRoot(config = {}) { return { ngModule: AngularSvgIconModule, providers: [ SVG_ICON_REGISTRY_PROVIDER, config.loader || { provide: SvgLoader, useClass: SvgHttpLoader } ] }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: AngularSvgIconModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.0.5", ngImport: i0, type: AngularSvgIconModule, imports: [SvgIconComponent], exports: [SvgIconComponent] }); } static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: AngularSvgIconModule }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: AngularSvgIconModule, decorators: [{ type: NgModule, args: [{ imports: [ SvgIconComponent ], exports: [SvgIconComponent] }] }] }); /** * Generated bundle index. Do not edit. */ export { AngularSvgIconModule, SERVER_URL, SVG_ICON_REGISTRY_PROVIDER, SVG_ICON_REGISTRY_PROVIDER_FACTORY, SvgHttpLoader, SvgIconComponent, SvgIconRegistryService, SvgLoader, provideAngularSvgIcon }; //# sourceMappingURL=angular-svg-icon.mjs.map