/** * @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.dev/license */ import {DOCUMENT} from '@angular/common'; import { AfterViewChecked, booleanAttribute, ChangeDetectionStrategy, Component, ElementRef, ErrorHandler, inject, InjectionToken, Input, OnDestroy, OnInit, ViewEncapsulation, HostAttributeToken, } from '@angular/core'; import {ThemePalette} from '@angular/material/core'; import {Subscription} from 'rxjs'; import {take} from 'rxjs/operators'; import {MatIconRegistry} from './icon-registry'; /** Default options for `mat-icon`. */ export interface MatIconDefaultOptions { /** * Theme color of the icon. This API is supported in M2 themes only, it * has no effect in M3 themes. * * For information on applying color variants in M3, see * https://material.angular.io/guide/theming#using-component-color-variants. */ color?: ThemePalette; /** Font set that the icon is a part of. */ fontSet?: string; } /** Injection token to be used to override the default options for `mat-icon`. */ export const MAT_ICON_DEFAULT_OPTIONS = new InjectionToken( 'MAT_ICON_DEFAULT_OPTIONS', ); /** * 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 `fontIcon` attribute or the * content of the `` component. If you register a custom font class, don't forget to also * include the special class `mat-ligature-font`. It is recommended to use the attribute alternative * to prevent the ligature text to be selectable and to appear in search engine results. * 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://fontawesome-v4.github.io/examples/ * Example: * `` */ @Component({ template: '', selector: 'mat-icon', exportAs: 'matIcon', styleUrl: 'icon.css', host: { 'role': 'img', 'class': 'mat-icon notranslate', '[class]': 'color ? "mat-" + color : ""', '[attr.data-mat-icon-type]': '_usingFontIcon() ? "font" : "svg"', '[attr.data-mat-icon-name]': '_svgName || fontIcon', '[attr.data-mat-icon-namespace]': '_svgNamespace || fontSet', '[attr.fontIcon]': '_usingFontIcon() ? fontIcon : null', '[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 implements OnInit, AfterViewChecked, OnDestroy { readonly _elementRef = inject>(ElementRef); private _iconRegistry = inject(MatIconRegistry); private _location = inject(MAT_ICON_LOCATION); private readonly _errorHandler = inject(ErrorHandler); private _defaultColor: ThemePalette; /** * Theme color of the icon. This API is supported in M2 themes only, it * has no effect in M3 themes. * * For information on applying color variants in M3, see * https://material.angular.io/guide/theming#using-component-color-variants. */ @Input() get color() { return this._color || this._defaultColor; } set color(value: string | null | undefined) { this._color = value; } private _color: string | null | undefined; /** * Whether the icon should be inlined, automatically sizing the icon to match the font size of * the element the icon is contained in. */ @Input({transform: booleanAttribute}) inline: boolean = false; /** Name of the icon in the SVG icon set. */ @Input() get svgIcon(): string { return this._svgIcon; } set svgIcon(value: string) { if (value !== this._svgIcon) { if (value) { this._updateSvgIcon(value); } else if (this._svgIcon) { this._clearSvgElement(); } this._svgIcon = value; } } private _svgIcon: string; /** Font set that the icon is a part of. */ @Input() get fontSet(): string { return this._fontSet; } set fontSet(value: string) { const newValue = this._cleanupFontValue(value); if (newValue !== this._fontSet) { this._fontSet = newValue; this._updateFontIconClasses(); } } private _fontSet: string; /** Name of an icon within a font set. */ @Input() get fontIcon(): string { return this._fontIcon; } set fontIcon(value: string) { const newValue = this._cleanupFontValue(value); if (newValue !== this._fontIcon) { this._fontIcon = newValue; this._updateFontIconClasses(); } } private _fontIcon: string; private _previousFontSetClass: string[] = []; private _previousFontIconClass: string; _svgName: string | null; _svgNamespace: string | null; /** 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; /** Subscription to the current in-progress SVG icon request. */ private _currentIconFetch = Subscription.EMPTY; constructor(...args: unknown[]); constructor() { const ariaHidden = inject(new HostAttributeToken('aria-hidden'), {optional: true}); const defaults = inject(MAT_ICON_DEFAULT_OPTIONS, {optional: true}); if (defaults) { if (defaults.color) { this.color = this._defaultColor = defaults.color; } if (defaults.fontSet) { this.fontSet = defaults.fontSet; } } // 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) { this._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}"`); // TODO: add an ngDevMode check } } 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. this._updateFontIconClasses(); } ngAfterViewChecked() { const cachedElements = this._elementsWithExternalReferences; if (cachedElements && 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() { this._currentIconFetch.unsubscribe(); if (this._elementsWithExternalReferences) { this._elementsWithExternalReferences.clear(); } } _usingFontIcon(): boolean { return !this.svgIcon; } private _setSvgElement(svg: SVGElement) { this._clearSvgElement(); // 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. 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') { child.remove(); } } } private _updateFontIconClasses() { if (!this._usingFontIcon()) { return; } const elem: HTMLElement = this._elementRef.nativeElement; const fontSetClasses = ( this.fontSet ? this._iconRegistry.classNameForFontAlias(this.fontSet).split(/ +/) : this._iconRegistry.getDefaultFontSetClass() ).filter(className => className.length > 0); this._previousFontSetClass.forEach(className => elem.classList.remove(className)); fontSetClasses.forEach(className => elem.classList.add(className)); this._previousFontSetClass = fontSetClasses; if ( this.fontIcon !== this._previousFontIconClass && !fontSetClasses.includes('mat-ligature-font') ) { 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]}); } }); } } /** Sets a new SVG icon with a particular name. */ private _updateSvgIcon(rawName: string | undefined) { this._svgNamespace = null; this._svgName = null; this._currentIconFetch.unsubscribe(); if (rawName) { const [namespace, iconName] = this._splitIconName(rawName); if (namespace) { this._svgNamespace = namespace; } if (iconName) { this._svgName = iconName; } this._currentIconFetch = this._iconRegistry .getNamedSvgIcon(iconName, namespace) .pipe(take(1)) .subscribe( svg => this._setSvgElement(svg), (err: Error) => { const errorMessage = `Error retrieving icon ${namespace}:${iconName}! ${err.message}`; this._errorHandler.handleError(new Error(errorMessage)); }, ); } } }