Skip to content

Commit

Permalink
feat(icon): allow viewBox to be configured when registering icons (an…
Browse files Browse the repository at this point in the history
…gular#16320)

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 angular#2981.
Fixes angular#16293.
  • Loading branch information
crisbeto authored and jelbourn committed Jul 19, 2019
1 parent 3bd160b commit 3638886
Show file tree
Hide file tree
Showing 3 changed files with 129 additions and 37 deletions.
72 changes: 43 additions & 29 deletions src/material/icon/icon-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -136,17 +141,17 @@ 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);
}

/**
* Registers an icon using an HTML string in the default namespace.
* @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);
}

/**
Expand All @@ -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));
}

/**
Expand All @@ -165,56 +171,58 @@ 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);
}

/**
* Registers an icon set by URL in the specified namespace.
* @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));
}

/**
* Registers an icon set using an HTML string in the specified namespace.
* @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) {
throw getMatIconFailedToSanitizeLiteralError(literal);
}

const svgElement = this._svgElementFromString(sanitizedLiteral);
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement));
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement, options));
}

/**
Expand Down Expand Up @@ -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;
}
Expand All @@ -410,7 +418,7 @@ export class MatIconRegistry implements OnDestroy {
*/
private _loadSvgIconFromConfig(config: SvgIconConfig): Observable<SVGElement> {
return this._fetchUrl(config.url)
.pipe(map(svgText => this._createSvgElementForSingleIcon(svgText)));
.pipe(map(svgText => this._createSvgElementForSingleIcon(svgText, config.options)));
}

/**
Expand All @@ -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;
}

Expand All @@ -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}"]`);
Expand All @@ -465,14 +474,14 @@ export class MatIconRegistry implements OnDestroy {
// If the icon node is itself an <svg> node, clone and return it directly. If not, set it as
// the content of a new <svg> node.
if (iconElement.nodeName.toLowerCase() === 'svg') {
return this._setSvgAttributes(iconElement as SVGElement);
return this._setSvgAttributes(iconElement as SVGElement, options);
}

// If the node is a <symbol>, it won't be rendered so we have to convert it into <svg>. Note
// that the same could be achieved by referring to it via <use href="#id">, however the <use>
// 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
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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;
}

Expand Down
74 changes: 74 additions & 0 deletions src/material/icon/icon.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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'));

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(`
<svg>
Expand Down
20 changes: 12 additions & 8 deletions tools/public_api_guard/material/icon.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MatIconLocation>;

export declare function MAT_ICON_LOCATION_FACTORY(): MatIconLocation;
Expand All @@ -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<SVGElement>;
Expand Down

0 comments on commit 3638886

Please sign in to comment.