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
JavaScript
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