From f37839357c8839e3b9660b282404454ba3db09a6 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 30 Jun 2019 10:14:08 +0200 Subject: [PATCH] feat(icon): allow viewBox to be configured when registering icons This has been a long-standing feature request that has recently popped up again. Allows consumers to specify a `viewBox` for icons and icon sets when they're being registered. Fixes #2981. Fixes #16293. --- src/material/icon/icon-registry.ts | 72 +++++++++++++--------- src/material/icon/icon.spec.ts | 74 +++++++++++++++++++++++ tools/public_api_guard/material/icon.d.ts | 20 +++--- 3 files changed, 129 insertions(+), 37 deletions(-) diff --git a/src/material/icon/icon-registry.ts b/src/material/icon/icon-registry.ts index d85747bf59a2..787781eb29a2 100644 --- a/src/material/icon/icon-registry.ts +++ b/src/material/icon/icon-registry.ts @@ -64,6 +64,11 @@ export function getMatIconFailedToSanitizeLiteralError(literal: SafeHtml): Error `Angular's DomSanitizer. Attempted literal was "${literal}".`); } +/** Options that can be used to configure how an icon or the icons in an icon set are presented. */ +export interface IconOptions { + /** View box to set on the icon. */ + viewBox?: string; +} /** * Configuration for an icon, including the URL and possibly the cached SVG element. @@ -73,9 +78,9 @@ class SvgIconConfig { url: SafeResourceUrl | null; svgElement: SVGElement | null; - constructor(url: SafeResourceUrl); - constructor(svgElement: SVGElement); - constructor(data: SafeResourceUrl | SVGElement) { + constructor(url: SafeResourceUrl, options?: IconOptions); + constructor(svgElement: SVGElement, options?: IconOptions); + constructor(data: SafeResourceUrl | SVGElement, public options?: IconOptions) { // Note that we can't use `instanceof SVGElement` here, // because it'll break during server-side rendering. if (!!(data as any).nodeName) { @@ -136,8 +141,8 @@ export class MatIconRegistry implements OnDestroy { * @param iconName Name under which the icon should be registered. * @param url */ - addSvgIcon(iconName: string, url: SafeResourceUrl): this { - return this.addSvgIconInNamespace('', iconName, url); + addSvgIcon(iconName: string, url: SafeResourceUrl, options?: IconOptions): this { + return this.addSvgIconInNamespace('', iconName, url, options); } /** @@ -145,8 +150,8 @@ export class MatIconRegistry implements OnDestroy { * @param iconName Name under which the icon should be registered. * @param literal SVG source of the icon. */ - addSvgIconLiteral(iconName: string, literal: SafeHtml): this { - return this.addSvgIconLiteralInNamespace('', iconName, literal); + addSvgIconLiteral(iconName: string, literal: SafeHtml, options?: IconOptions): this { + return this.addSvgIconLiteralInNamespace('', iconName, literal, options); } /** @@ -155,8 +160,9 @@ export class MatIconRegistry implements OnDestroy { * @param iconName Name under which the icon should be registered. * @param url */ - addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl): this { - return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url)); + addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl, + options?: IconOptions): this { + return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url, options)); } /** @@ -165,31 +171,32 @@ export class MatIconRegistry implements OnDestroy { * @param iconName Name under which the icon should be registered. * @param literal SVG source of the icon. */ - addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml): this { + addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml, + options?: IconOptions): this { const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal); if (!sanitizedLiteral) { throw getMatIconFailedToSanitizeLiteralError(literal); } - const svgElement = this._createSvgElementForSingleIcon(sanitizedLiteral); - return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(svgElement)); + const svgElement = this._createSvgElementForSingleIcon(sanitizedLiteral, options); + return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(svgElement, options)); } /** * Registers an icon set by URL in the default namespace. * @param url */ - addSvgIconSet(url: SafeResourceUrl): this { - return this.addSvgIconSetInNamespace('', url); + addSvgIconSet(url: SafeResourceUrl, options?: IconOptions): this { + return this.addSvgIconSetInNamespace('', url, options); } /** * Registers an icon set using an HTML string in the default namespace. * @param literal SVG source of the icon set. */ - addSvgIconSetLiteral(literal: SafeHtml): this { - return this.addSvgIconSetLiteralInNamespace('', literal); + addSvgIconSetLiteral(literal: SafeHtml, options?: IconOptions): this { + return this.addSvgIconSetLiteralInNamespace('', literal, options); } /** @@ -197,8 +204,8 @@ export class MatIconRegistry implements OnDestroy { * @param namespace Namespace in which to register the icon set. * @param url */ - addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl): this { - return this._addSvgIconSetConfig(namespace, new SvgIconConfig(url)); + addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl, options?: IconOptions): this { + return this._addSvgIconSetConfig(namespace, new SvgIconConfig(url, options)); } /** @@ -206,7 +213,8 @@ export class MatIconRegistry implements OnDestroy { * @param namespace Namespace in which to register the icon set. * @param literal SVG source of the icon set. */ - addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml): this { + addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml, + options?: IconOptions): this { const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal); if (!sanitizedLiteral) { @@ -214,7 +222,7 @@ export class MatIconRegistry implements OnDestroy { } const svgElement = this._svgElementFromString(sanitizedLiteral); - return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement)); + return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement, options)); } /** @@ -395,7 +403,7 @@ export class MatIconRegistry implements OnDestroy { for (let i = iconSetConfigs.length - 1; i >= 0; i--) { const config = iconSetConfigs[i]; if (config.svgElement) { - const foundIcon = this._extractSvgIconFromSet(config.svgElement, iconName); + const foundIcon = this._extractSvgIconFromSet(config.svgElement, iconName, config.options); if (foundIcon) { return foundIcon; } @@ -410,7 +418,7 @@ export class MatIconRegistry implements OnDestroy { */ private _loadSvgIconFromConfig(config: SvgIconConfig): Observable { return this._fetchUrl(config.url) - .pipe(map(svgText => this._createSvgElementForSingleIcon(svgText))); + .pipe(map(svgText => this._createSvgElementForSingleIcon(svgText, config.options))); } /** @@ -437,9 +445,9 @@ export class MatIconRegistry implements OnDestroy { /** * Creates a DOM element from the given SVG string, and adds default attributes. */ - private _createSvgElementForSingleIcon(responseText: string): SVGElement { + private _createSvgElementForSingleIcon(responseText: string, options?: IconOptions): SVGElement { const svg = this._svgElementFromString(responseText); - this._setSvgAttributes(svg); + this._setSvgAttributes(svg, options); return svg; } @@ -448,7 +456,8 @@ export class MatIconRegistry implements OnDestroy { * tag matches the specified name. If found, copies the nested element to a new SVG element and * returns it. Returns null if no matching element is found. */ - private _extractSvgIconFromSet(iconSet: SVGElement, iconName: string): SVGElement | null { + private _extractSvgIconFromSet(iconSet: SVGElement, iconName: string, + options?: IconOptions): SVGElement | null { // Use the `id="iconName"` syntax in order to escape special // characters in the ID (versus using the #iconName syntax). const iconSource = iconSet.querySelector(`[id="${iconName}"]`); @@ -465,14 +474,14 @@ export class MatIconRegistry implements OnDestroy { // If the icon node is itself an node, clone and return it directly. If not, set it as // the content of a new node. if (iconElement.nodeName.toLowerCase() === 'svg') { - return this._setSvgAttributes(iconElement as SVGElement); + return this._setSvgAttributes(iconElement as SVGElement, options); } // If the node is a , it won't be rendered so we have to convert it into . Note // that the same could be achieved by referring to it via , however the // tag is problematic on Firefox, because it needs to include the current page path. if (iconElement.nodeName.toLowerCase() === 'symbol') { - return this._setSvgAttributes(this._toSvgElement(iconElement)); + return this._setSvgAttributes(this._toSvgElement(iconElement), options); } // createElement('SVG') doesn't work as expected; the DOM ends up with @@ -484,7 +493,7 @@ export class MatIconRegistry implements OnDestroy { // Clone the node so we don't remove it from the parent icon set element. svg.appendChild(iconElement); - return this._setSvgAttributes(svg); + return this._setSvgAttributes(svg, options); } /** @@ -520,12 +529,17 @@ export class MatIconRegistry implements OnDestroy { /** * Sets the default attributes for an SVG element to be used as an icon. */ - private _setSvgAttributes(svg: SVGElement): SVGElement { + private _setSvgAttributes(svg: SVGElement, options?: IconOptions): SVGElement { svg.setAttribute('fit', ''); svg.setAttribute('height', '100%'); svg.setAttribute('width', '100%'); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); svg.setAttribute('focusable', 'false'); // Disable IE11 default behavior to make SVGs focusable. + + if (options && options.viewBox) { + svg.setAttribute('viewBox', options.viewBox); + } + return svg; } diff --git a/src/material/icon/icon.spec.ts b/src/material/icon/icon.spec.ts index a14eace6a48c..f7da96d6e881 100644 --- a/src/material/icon/icon.spec.ts +++ b/src/material/icon/icon.spec.ts @@ -215,6 +215,29 @@ describe('MatIcon', () => { tick(); })); + it('should be able to set the viewBox when registering a single SVG icon', fakeAsync(() => { + iconRegistry.addSvgIcon('fluffy', trustUrl('cat.svg'), {viewBox: '0 0 27 27'}); + iconRegistry.addSvgIcon('fido', trustUrl('dog.svg'), {viewBox: '0 0 43 43'}); + + let fixture = TestBed.createComponent(IconFromSvgName); + let svgElement: SVGElement; + const testComponent = fixture.componentInstance; + const iconElement = fixture.debugElement.nativeElement.querySelector('mat-icon'); + + testComponent.iconName = 'fido'; + fixture.detectChanges(); + http.expectOne('dog.svg').flush(FAKE_SVGS.dog); + svgElement = verifyAndGetSingleSvgChild(iconElement); + expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43'); + + // Change the icon, and the SVG element should be replaced. + testComponent.iconName = 'fluffy'; + fixture.detectChanges(); + http.expectOne('cat.svg').flush(FAKE_SVGS.cat); + svgElement = verifyAndGetSingleSvgChild(iconElement); + expect(svgElement.getAttribute('viewBox')).toBe('0 0 27 27'); + })); + it('should throw an error when using an untrusted icon url', () => { iconRegistry.addSvgIcon('fluffy', 'farm-set-1.svg'); @@ -449,6 +472,22 @@ describe('MatIcon', () => { }).not.toThrow(); }); + it('should be able to configure the viewBox for the icon set', () => { + iconRegistry.addSvgIconSet(trustUrl('arrow-set.svg'), {viewBox: '0 0 43 43'}); + + const fixture = TestBed.createComponent(IconFromSvgName); + const testComponent = fixture.componentInstance; + const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon'); + let svgElement: any; + + testComponent.iconName = 'left-arrow'; + fixture.detectChanges(); + http.expectOne('arrow-set.svg').flush(FAKE_SVGS.arrows); + svgElement = verifyAndGetSingleSvgChild(matIconElement); + + expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43'); + }); + it('should remove the SVG element from the DOM when the binding is cleared', () => { iconRegistry.addSvgIconSet(trustUrl('arrow-set.svg')); @@ -518,6 +557,26 @@ describe('MatIcon', () => { tick(); })); + it('should be able to configure the icon viewBox', fakeAsync(() => { + iconRegistry.addSvgIconLiteral('fluffy', trustHtml(FAKE_SVGS.cat), {viewBox: '0 0 43 43'}); + iconRegistry.addSvgIconLiteral('fido', trustHtml(FAKE_SVGS.dog), {viewBox: '0 0 27 27'}); + + let fixture = TestBed.createComponent(IconFromSvgName); + let svgElement: SVGElement; + const testComponent = fixture.componentInstance; + const iconElement = fixture.debugElement.nativeElement.querySelector('mat-icon'); + + testComponent.iconName = 'fido'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(iconElement); + expect(svgElement.getAttribute('viewBox')).toBe('0 0 27 27'); + + testComponent.iconName = 'fluffy'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(iconElement); + expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43'); + })); + it('should throw an error when using untrusted HTML', () => { // Stub out console.warn so we don't pollute our logs with Angular's warnings. // Jasmine will tear the spy down at the end of the test. @@ -631,6 +690,21 @@ describe('MatIcon', () => { expect(svgElement.getAttribute('viewBox')).toBeFalsy(); }); + it('should be able to configure the viewBox for the icon set', () => { + iconRegistry.addSvgIconSetLiteral(trustHtml(FAKE_SVGS.arrows), {viewBox: '0 0 43 43'}); + + const fixture = TestBed.createComponent(IconFromSvgName); + const testComponent = fixture.componentInstance; + const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon'); + let svgElement: any; + + testComponent.iconName = 'left-arrow'; + fixture.detectChanges(); + svgElement = verifyAndGetSingleSvgChild(matIconElement); + + expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43'); + }); + it('should add an extra string to the end of `style` tags inside SVG', fakeAsync(() => { iconRegistry.addSvgIconLiteral('fido', trustHtml(` diff --git a/tools/public_api_guard/material/icon.d.ts b/tools/public_api_guard/material/icon.d.ts index 5cbbe6fcec05..80c1c04ad45d 100644 --- a/tools/public_api_guard/material/icon.d.ts +++ b/tools/public_api_guard/material/icon.d.ts @@ -14,6 +14,10 @@ export declare const ICON_REGISTRY_PROVIDER: { export declare function ICON_REGISTRY_PROVIDER_FACTORY(parentRegistry: MatIconRegistry, httpClient: HttpClient, sanitizer: DomSanitizer, document?: any): MatIconRegistry; +export interface IconOptions { + viewBox?: string; +} + export declare const MAT_ICON_LOCATION: InjectionToken; export declare function MAT_ICON_LOCATION_FACTORY(): MatIconLocation; @@ -40,14 +44,14 @@ export declare class MatIconModule { export declare class MatIconRegistry implements OnDestroy { constructor(_httpClient: HttpClient, _sanitizer: DomSanitizer, document: any); - addSvgIcon(iconName: string, url: SafeResourceUrl): this; - addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl): this; - addSvgIconLiteral(iconName: string, literal: SafeHtml): this; - addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml): this; - addSvgIconSet(url: SafeResourceUrl): this; - addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl): this; - addSvgIconSetLiteral(literal: SafeHtml): this; - addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml): this; + addSvgIcon(iconName: string, url: SafeResourceUrl, options?: IconOptions): this; + addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl, options?: IconOptions): this; + addSvgIconLiteral(iconName: string, literal: SafeHtml, options?: IconOptions): this; + addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml, options?: IconOptions): this; + addSvgIconSet(url: SafeResourceUrl, options?: IconOptions): this; + addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl, options?: IconOptions): this; + addSvgIconSetLiteral(literal: SafeHtml, options?: IconOptions): this; + addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml, options?: IconOptions): this; classNameForFontAlias(alias: string): string; getDefaultFontSetClass(): string; getNamedSvgIcon(name: string, namespace?: string): Observable;