Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(icon): allow viewBox to be configured when registering icons #16320

Merged
merged 1 commit into from
Jul 19, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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