/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ import {take} from 'rxjs/operators'; import { Attribute, ChangeDetectionStrategy, Component, ElementRef, Input, OnChanges, OnInit, SimpleChanges, ViewEncapsulation, Optional, InjectionToken, inject, Inject, OnDestroy, AfterViewChecked, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; import {CanColor, CanColorCtor, mixinColor} from '@angular/material/core'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {MatIconRegistry} from './icon-registry'; // Boilerplate for applying mixins to MatIcon. /** @docs-private */ class MatIconBase { constructor(public _elementRef: ElementRef) {} } const _MatIconMixinBase: CanColorCtor & typeof MatIconBase = mixinColor(MatIconBase); /** * Injection token used to provide the current location to `MatIcon`. * Used to handle server-side rendering and to stub out during unit tests. * @docs-private */ export const MAT_ICON_LOCATION = new InjectionToken('mat-icon-location', { providedIn: 'root', factory: MAT_ICON_LOCATION_FACTORY }); /** * Stubbed out location for `MatIcon`. * @docs-private */ export interface MatIconLocation { getPathname: () => string; } /** @docs-private */ export function MAT_ICON_LOCATION_FACTORY(): MatIconLocation { const _document = inject(DOCUMENT); const _location = _document ? _document.location : null; return { // Note that this needs to be a function, rather than a property, because Angular // will only resolve it once, but we want the current path on each call. getPathname: () => _location ? (_location.pathname + _location.search) : '' }; } /** SVG attributes that accept a FuncIRI (e.g. `url()`). */ const funcIriAttributes = [ 'clip-path', 'color-profile', 'src', 'cursor', 'fill', 'filter', 'marker', 'marker-start', 'marker-mid', 'marker-end', 'mask', 'stroke' ]; /** Selector that can be used to find all elements that are using a `FuncIRI`. */ const funcIriAttributeSelector = funcIriAttributes.map(attr => `[${attr}]`).join(', '); /** Regex that can be used to extract the id out of a FuncIRI. */ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/; /** * Component to display an icon. It can be used in the following ways: * * - Specify the svgIcon input to load an SVG icon from a URL previously registered with the * addSvgIcon, addSvgIconInNamespace, addSvgIconSet, or addSvgIconSetInNamespace methods of * MatIconRegistry. If the svgIcon value contains a colon it is assumed to be in the format * "[namespace]:[name]", if not the value will be the name of an icon in the default namespace. * Examples: * ` * ` * * - Use a font ligature as an icon by putting the ligature text in the content of the `` * component. By default the Material icons font is used as described at * http://google.github.io/material-design-icons/#icon-font-for-the-web. You can specify an * alternate font by setting the fontSet input to either the CSS class to apply to use the * desired font, or to an alias previously registered with MatIconRegistry.registerFontClassAlias. * Examples: * `home * sun` * * - Specify a font glyph to be included via CSS rules by setting the fontSet input to specify the * font, and the fontIcon input to specify the icon. Typically the fontIcon will specify a * CSS class which causes the glyph to be displayed via a :before selector, as in * https://fortawesome.github.io/Font-Awesome/examples/ * Example: * `` */ @Component({ moduleId: module.id, template: '', selector: 'mat-icon', exportAs: 'matIcon', styleUrls: ['icon.css'], inputs: ['color'], host: { 'role': 'img', 'class': 'mat-icon notranslate', '[class.mat-icon-inline]': 'inline', '[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, AfterViewChecked, CanColor, OnDestroy { /** * Whether the icon should be inlined, automatically sizing the icon to match the font size of * the element the icon is contained in. */ @Input() get inline(): boolean { return this._inline; } set inline(inline: boolean) { this._inline = coerceBooleanProperty(inline); } private _inline: boolean = false; /** Name of the icon in the SVG icon set. */ @Input() svgIcon: string; /** Font set that the icon is a part of. */ @Input() get fontSet(): string { return this._fontSet; } set fontSet(value: string) { this._fontSet = this._cleanupFontValue(value); } private _fontSet: string; /** Name of an icon within a font set. */ @Input() get fontIcon(): string { return this._fontIcon; } set fontIcon(value: string) { this._fontIcon = this._cleanupFontValue(value); } private _fontIcon: string; private _previousFontSetClass: string; private _previousFontIconClass: string; /** Keeps track of the current page path. */ private _previousPath?: string; /** Keeps track of the elements and attributes that we've prefixed with the current path. */ private _elementsWithExternalReferences?: Map; constructor( elementRef: ElementRef, private _iconRegistry: MatIconRegistry, @Attribute('aria-hidden') ariaHidden: string, /** * @deprecated `location` parameter to be made required. * @breaking-change 8.0.0 */ @Optional() @Inject(MAT_ICON_LOCATION) private _location?: MatIconLocation) { super(elementRef); // If the user has not explicitly set aria-hidden, mark the icon as hidden, as this is // the right thing to do for the majority of icon use-cases. if (!ariaHidden) { elementRef.nativeElement.setAttribute('aria-hidden', 'true'); } } /** * Splits an svgIcon binding value into its icon set and icon name components. * Returns a 2-element array of [(icon set), (icon name)]. * The separator for the two fields is ':'. If there is no separator, an empty * string is returned for the icon set and the entire value is returned for * the icon name. If the argument is falsy, returns an array of two empty strings. * Throws an error if the name contains two or more ':' separators. * Examples: * `'social:cake' -> ['social', 'cake'] * 'penguin' -> ['', 'penguin'] * null -> ['', ''] * 'a:b:c' -> (throws Error)` */ private _splitIconName(iconName: string): [string, string] { if (!iconName) { return ['', '']; } const parts = iconName.split(':'); switch (parts.length) { case 1: return ['', parts[0]]; // Use default namespace. case 2: return <[string, string]>parts; default: throw Error(`Invalid icon name: "${iconName}"`); } } ngOnChanges(changes: SimpleChanges) { // Only update the inline SVG icon if the inputs changed, to avoid unnecessary DOM operations. const svgIconChanges = changes['svgIcon']; if (svgIconChanges) { if (this.svgIcon) { const [namespace, iconName] = this._splitIconName(this.svgIcon); this._iconRegistry.getNamedSvgIcon(iconName, namespace).pipe(take(1)).subscribe( svg => this._setSvgElement(svg), (err: Error) => console.log(`Error retrieving icon: ${err.message}`) ); } else if (svgIconChanges.previousValue) { this._clearSvgElement(); } } if (this._usingFontIcon()) { this._updateFontIconClasses(); } } ngOnInit() { // Update font classes because ngOnChanges won't be called if none of the inputs are present, // e.g. arrow In this case we need to add a CSS class for the default font. if (this._usingFontIcon()) { this._updateFontIconClasses(); } } ngAfterViewChecked() { const cachedElements = this._elementsWithExternalReferences; if (cachedElements && this._location && cachedElements.size) { const newPath = this._location.getPathname(); // We need to check whether the URL has changed on each change detection since // the browser doesn't have an API that will let us react on link clicks and // we can't depend on the Angular router. The references need to be updated, // because while most browsers don't care whether the URL is correct after // the first render, Safari will break if the user navigates to a different // page and the SVG isn't re-rendered. if (newPath !== this._previousPath) { this._previousPath = newPath; this._prependPathToReferences(newPath); } } } ngOnDestroy() { if (this._elementsWithExternalReferences) { this._elementsWithExternalReferences.clear(); } } private _usingFontIcon(): boolean { return !this.svgIcon; } private _setSvgElement(svg: SVGElement) { this._clearSvgElement(); // Workaround for IE11 and Edge ignoring `style` tags inside dynamically-created SVGs. // See: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/10898469/ // Do this before inserting the element into the DOM, in order to avoid a style recalculation. const styleTags = svg.querySelectorAll('style') as NodeListOf; for (let i = 0; i < styleTags.length; i++) { styleTags[i].textContent += ' '; } // Note: we do this fix here, rather than the icon registry, because the // references have to point to the URL at the time that the icon was created. if (this._location) { const path = this._location.getPathname(); this._previousPath = path; this._cacheChildrenWithExternalReferences(svg); this._prependPathToReferences(path); } this._elementRef.nativeElement.appendChild(svg); } private _clearSvgElement() { const layoutElement: HTMLElement = this._elementRef.nativeElement; let childCount = layoutElement.childNodes.length; if (this._elementsWithExternalReferences) { this._elementsWithExternalReferences.clear(); } // Remove existing non-element child nodes and SVGs, and add the new SVG element. Note that // we can't use innerHTML, because IE will throw if the element has a data binding. while (childCount--) { const child = layoutElement.childNodes[childCount]; // 1 corresponds to Node.ELEMENT_NODE. We remove all non-element nodes in order to get rid // of any loose text nodes, as well as any SVG elements in order to remove any old icons. if (child.nodeType !== 1 || child.nodeName.toLowerCase() === 'svg') { layoutElement.removeChild(child); } } } private _updateFontIconClasses() { if (!this._usingFontIcon()) { return; } const elem: HTMLElement = this._elementRef.nativeElement; const fontSetClass = this.fontSet ? this._iconRegistry.classNameForFontAlias(this.fontSet) : this._iconRegistry.getDefaultFontSetClass(); if (fontSetClass != this._previousFontSetClass) { if (this._previousFontSetClass) { elem.classList.remove(this._previousFontSetClass); } if (fontSetClass) { elem.classList.add(fontSetClass); } this._previousFontSetClass = fontSetClass; } if (this.fontIcon != this._previousFontIconClass) { if (this._previousFontIconClass) { elem.classList.remove(this._previousFontIconClass); } if (this.fontIcon) { elem.classList.add(this.fontIcon); } this._previousFontIconClass = this.fontIcon; } } /** * Cleans up a value to be used as a fontIcon or fontSet. * Since the value ends up being assigned as a CSS class, we * have to trim the value and omit space-separated values. */ private _cleanupFontValue(value: string) { return typeof value === 'string' ? value.trim().split(' ')[0] : value; } /** * Prepends the current path to all elements that have an attribute pointing to a `FuncIRI` * reference. This is required because WebKit browsers require references to be prefixed with * the current path, if the page has a `base` tag. */ private _prependPathToReferences(path: string) { const elements = this._elementsWithExternalReferences; if (elements) { elements.forEach((attrs, element) => { attrs.forEach(attr => { element.setAttribute(attr.name, `url('${path}#${attr.value}')`); }); }); } } /** * Caches the children of an SVG element that have `url()` * references that we need to prefix with the current path. */ private _cacheChildrenWithExternalReferences(element: SVGElement) { const elementsWithFuncIri = element.querySelectorAll(funcIriAttributeSelector); const elements = this._elementsWithExternalReferences = this._elementsWithExternalReferences || new Map(); for (let i = 0; i < elementsWithFuncIri.length; i++) { funcIriAttributes.forEach(attr => { const elementWithReference = elementsWithFuncIri[i]; const value = elementWithReference.getAttribute(attr); const match = value ? value.match(funcIriPattern) : null; if (match) { let attributes = elements.get(elementWithReference); if (!attributes) { attributes = []; elements.set(elementWithReference, attributes); } attributes!.push({name: attr, value: match[1]}); } }); } } }