Skip to content

feat(icon): allow SVG icons to be registered from strings #10757

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

Merged
merged 1 commit into from
Apr 11, 2018
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
152 changes: 119 additions & 33 deletions src/demo-app/icon/assets/core-icon-set.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions src/demo-app/icon/icon-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@
<mat-icon svgIcon="thumb-up"></mat-icon>
</p>

<p>
From inline template with MatIconProvider:
<mat-icon svgIcon="bike" class="green"></mat-icon>
<mat-icon svgIcon="bike"></mat-icon>
</p>

<p>
Mirrored in RTL:
<mat-icon class="mat-icon-rtl-mirror green" svgIcon="thumb-up"></mat-icon>
<mat-icon class="mat-icon-rtl-mirror" svgIcon="thumb-up"></mat-icon>
<mat-icon class="mat-icon-rtl-mirror green" svgIcon="bike"></mat-icon>
<mat-icon class="mat-icon-rtl-mirror" svgIcon="bike"></mat-icon>
</p>

<p>
Expand All @@ -22,6 +30,13 @@
<mat-icon svgIcon="core:alarm"></mat-icon>
</p>

<p>
From inline icon set:
<mat-icon svgIcon="core-inline:account-balance"></mat-icon>
<mat-icon svgIcon="core-inline:account-balance-wallet"></mat-icon>
<mat-icon svgIcon="core-inline:account-box"></mat-icon>
</p>

<p>
Ligature from Material Icons font:
<mat-icon>home</mat-icon>
Expand Down
39 changes: 39 additions & 0 deletions src/demo-app/icon/icon-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,47 @@ export class IconDemo {
iconRegistry
.addSvgIcon('thumb-up',
sanitizer.bypassSecurityTrustResourceUrl('/icon/assets/thumbup-icon.svg'))
.addSvgIconLiteral('bike',
sanitizer.bypassSecurityTrustHtml(BIKE_ICON))
.addSvgIconSetInNamespace('core',
sanitizer.bypassSecurityTrustResourceUrl('/icon/assets/core-icon-set.svg'))
.addSvgIconSetLiteralInNamespace('core-inline',
sanitizer.bypassSecurityTrustHtml(INLINE_ICON_SET))
.registerFontClassAlias('fontawesome', 'fa');
}
}

const BIKE_ICON = `
<svg xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M15.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zM5 12c-2.8 0-5 2.2-5 ` +
`5s2.2 5 5 5 5-2.2 5-5-2.2-5-5-5zm0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 ` +
`1.6 3.5 3.5-1.6 3.5-3.5 3.5zm5.8-10l2.4-2.4.8.8c1.3 1.3 3 2.1 5.1 2.1V9c-1.5 ` +
`0-2.7-.6-3.6-1.5l-1.9-1.9c-.5-.4-1-.6-1.6-.6s-1.1.2-1.4.6L7.8 8.4c-.4.4-.6.9-.6 ` +
`1.4 0 .6.2 1.1.6 1.4L11 14v5h2v-6.2l-2.2-2.3zM19 12c-2.8 0-5 2.2-5 5s2.2 5 5 5 5-2.2 ` +
`5-5-2.2-5-5-5zm0 8.5c-1.9 0-3.5-1.6-3.5-3.5s1.6-3.5 3.5-3.5 3.5 1.6 3.5 3.5-1.6 ` +
`3.5-3.5 3.5z"/>
</svg>
`;

const INLINE_ICON_SET = `
<svg>
<defs>
<svg id="account-balance">
<path d="M4 10v7h3v-7H4zm6 0v7h3v-7h-3zM2 22h19v-3H2v3zm14-12v7h3v-` +
`7h-3zm-4.5-9L2 6v2h19V6l-9.5-5z"/>
</svg>
<svg id="account-balance-wallet">
<path d="M21 18v1c0 1.1-.9 2-2 2H5c-1.11 0-2-.9-2-2V5c0-1.1.89-2 2-` +
`2h14c1.1 0 2 .9 2 2v1h-9c-1.11 0-2 .9-2 2v8c0 1.1.89 2 2 2h9zm-9` +
`-2h10V8H12v8zm4-2.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"
/>
</svg>
<svg id="account-box">
<path d="M3 5v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H` +
`5c-1.11 0-2 .9-2 2zm12 4c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-` +
`3 3 1.34 3 3zm-9 8c0-2 4-3.1 6-3.1s6 1.1 6 3.1v1H6v-1z"/>
</svg>
</defs>
</svg>
`;
133 changes: 114 additions & 19 deletions src/lib/icon/icon-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
SecurityContext,
SkipSelf,
} from '@angular/core';
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
import {DomSanitizer, SafeResourceUrl, SafeHtml} from '@angular/platform-browser';
import {forkJoin, Observable, of as observableOf, throwError as observableThrow} from 'rxjs';
import {catchError, finalize, map, share, tap} from 'rxjs/operators';

Expand Down Expand Up @@ -48,18 +48,41 @@ export function getMatIconNoHttpProviderError(): Error {
* @param url URL that was attempted to be sanitized.
* @docs-private
*/
export function getMatIconFailedToSanitizeError(url: SafeResourceUrl): Error {
export function getMatIconFailedToSanitizeUrlError(url: SafeResourceUrl): Error {
return Error(`The URL provided to MatIconRegistry was not trusted as a resource URL ` +
`via Angular's DomSanitizer. Attempted URL was "${url}".`);
}

/**
* Returns an exception to be thrown when a HTML string couldn't be sanitized.
* @param literal HTML that was attempted to be sanitized.
* @docs-private
*/
export function getMatIconFailedToSanitizeLiteralError(literal: SafeHtml): Error {
return Error(`The literal provided to MatIconRegistry was not trusted as safe HTML by ` +
`Angular's DomSanitizer. Attempted literal was "${literal}".`);
}


/**
* Configuration for an icon, including the URL and possibly the cached SVG element.
* @docs-private
*/
class SvgIconConfig {
svgElement: SVGElement | null = null;
constructor(public url: SafeResourceUrl) { }
url: SafeResourceUrl | null;
svgElement: SVGElement | null;

constructor(url: SafeResourceUrl);
constructor(svgElement: SVGElement);
constructor(data: SafeResourceUrl | SVGElement) {
// Note that we can't use `instanceof SVGElement` here,
// because it'll break during server-side rendering.
if (!!(data as any).nodeName) {
this.svgElement = data as SVGElement;
} else {
this.url = data as SafeResourceUrl;
}
}
}

/**
Expand Down Expand Up @@ -116,16 +139,40 @@ export class MatIconRegistry {
return this.addSvgIconInNamespace('', iconName, url);
}

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

/**
* Registers an icon by URL in the specified namespace.
* @param namespace Namespace in which the icon should be registered.
* @param iconName Name under which the icon should be registered.
* @param url
*/
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl): this {
const key = iconKey(namespace, iconName);
this._svgIconConfigs.set(key, new SvgIconConfig(url));
return this;
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url));
}

/**
* Registers an icon using an HTML string in the specified namespace.
* @param namespace Namespace in which the icon should be registered.
* @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 {
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));
}

/**
Expand All @@ -136,21 +183,37 @@ export class MatIconRegistry {
return this.addSvgIconSetInNamespace('', url);
}

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

/**
* 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 {
const config = new SvgIconConfig(url);
const configNamespace = this._iconSetConfigs.get(namespace);
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(url));
}

if (configNamespace) {
configNamespace.push(config);
} else {
this._iconSetConfigs.set(namespace, [config]);
/**
* 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 {
const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal);

if (!sanitizedLiteral) {
throw getMatIconFailedToSanitizeLiteralError(literal);
}
return this;

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

/**
Expand Down Expand Up @@ -202,13 +265,13 @@ export class MatIconRegistry {
* @param safeUrl URL from which to fetch the SVG icon.
*/
getSvgIconFromUrl(safeUrl: SafeResourceUrl): Observable<SVGElement> {
let url = this._sanitizer.sanitize(SecurityContext.RESOURCE_URL, safeUrl);
const url = this._sanitizer.sanitize(SecurityContext.RESOURCE_URL, safeUrl);

if (!url) {
throw getMatIconFailedToSanitizeError(safeUrl);
throw getMatIconFailedToSanitizeUrlError(safeUrl);
}

let cachedIcon = this._cachedIconsByUrl.get(url);
const cachedIcon = this._cachedIconsByUrl.get(url);

if (cachedIcon) {
return observableOf(cloneSvg(cachedIcon));
Expand Down Expand Up @@ -461,15 +524,19 @@ export class MatIconRegistry {
* Returns an Observable which produces the string contents of the given URL. Results may be
* cached, so future calls with the same URL may not cause another HTTP request.
*/
private _fetchUrl(safeUrl: SafeResourceUrl): Observable<string> {
private _fetchUrl(safeUrl: SafeResourceUrl | null): Observable<string> {
if (!this._httpClient) {
throw getMatIconNoHttpProviderError();
}

if (safeUrl == null) {
throw Error(`Cannot fetch icon from URL "${safeUrl}".`);
}

const url = this._sanitizer.sanitize(SecurityContext.RESOURCE_URL, safeUrl);

if (!url) {
throw getMatIconFailedToSanitizeError(safeUrl);
throw getMatIconFailedToSanitizeUrlError(safeUrl);
}

// Store in-progress fetches to avoid sending a duplicate request for a URL when there is
Expand All @@ -491,6 +558,34 @@ export class MatIconRegistry {
this._inProgressUrlFetches.set(url, req);
return req;
}

/**
* Registers an icon config by name in the specified namespace.
* @param namespace Namespace in which to register the icon config.
* @param iconName Name under which to register the config.
* @param config Config to be registered.
*/
private _addSvgIconConfig(namespace: string, iconName: string, config: SvgIconConfig): this {
this._svgIconConfigs.set(iconKey(namespace, iconName), config);
return this;
}

/**
* Registers an icon set config in the specified namespace.
* @param namespace Namespace in which to register the icon config.
* @param config Config to be registered.
*/
private _addSvgIconSetConfig(namespace: string, config: SvgIconConfig): this {
const configNamespace = this._iconSetConfigs.get(namespace);

if (configNamespace) {
configNamespace.push(config);
} else {
this._iconSetConfigs.set(namespace, [config]);
}

return this;
}
}

/** @docs-private */
Expand Down
31 changes: 17 additions & 14 deletions src/lib/icon/icon.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ icon fonts and SVG icons, but not bitmap-based formats (png, jpg, etc.).

### Registering icons

`MatIconRegistry` is an injectable service that allows you to associate icon names with SVG URLs and
define aliases for CSS font classes. Its methods are discussed below and listed in the API summary.
`MatIconRegistry` is an injectable service that allows you to associate icon names with SVG URLs,
HTML strings and to define aliases for CSS font classes. Its methods are discussed below and listed
in the API summary.

### Font icons with ligatures

Expand Down Expand Up @@ -44,29 +45,31 @@ SVG content is the CSS
value. This makes SVG icons by default have the same color as surrounding text, and allows you to
change the color by setting the "color" style on the `mat-icon` element.

In order to prevent XSS vulnerabilities, any SVG URLs passed to the `MatIconRegistry` must be
marked as trusted resource URLs by using Angular's `DomSanitizer` service.
In order to prevent XSS vulnerabilities, any SVG URLs and HTML strings passed to the
`MatIconRegistry` must be marked as trusted by using Angular's `DomSanitizer` service.

Also note that all SVG icons are fetched via XmlHttpRequest, and due to the same-origin policy,
their URLs must be on the same domain as the containing page, or their servers must be configured
to allow cross-domain access.
Also note that all SVG icons, registered by URL, are fetched via XmlHttpRequest, and due to the
same-origin policy, their URLs must be on the same domain as the containing page, or their servers
must be configured to allow cross-domain access.

#### Named icons

To associate a name with an icon URL, use the `addSvgIcon` or `addSvgIconInNamespace` methods of
`MatIconRegistry`. After registering an icon, it can be displayed by setting the `svgIcon` input.
For an icon in the default namespace, use the name directly. For a non-default namespace, use the
format `[namespace]:[name]`.
To associate a name with an icon URL, use the `addSvgIcon`, `addSvgIconInNamespace`,
`addSvgIconLiteral` or `addSvgIconLiteralInNamespace` methods of `MatIconRegistry`. After
registering an icon, it can be displayed by setting the `svgIcon` input. For an icon in the
default namespace, use the name directly. For a non-default namespace, use the format
`[namespace]:[name]`.

#### Icon sets

Icon sets allow grouping multiple icons into a single SVG file. This is done by creating a single
root `<svg>` tag that contains multiple nested `<svg>` tags in its `<defs>` section. Each of these
nested tags is identified with an `id` attribute. This `id` is used as the name of the icon.

Icon sets are registered using the `addSvgIconSet` or `addSvgIconSetInNamespace` methods of
`MatIconRegistry`. After an icon set is registered, each of its embedded icons can be accessed by
their `id` attributes. To display an icon from an icon set, use the `svgIcon` input in the same way
Icon sets are registered using the `addSvgIconSet`, `addSvgIconSetInNamespace`,
`addSvgIconSetLiteral` or `addSvgIconSetLiteralInNamespace` methods of `MatIconRegistry`.
After an icon set is registered, each of its embedded icons can be accessed by their `id`
attributes. To display an icon from an icon set, use the `svgIcon` input in the same way
as for individually registered icons.

Multiple icon sets can be registered in the same namespace. Requesting an icon whose id appears in
Expand Down
Loading