Skip to content

Commit bad81e5

Browse files
committed
feat(icon): allow SVG icons to be registered from strings
Expands the icon registry to allow for icons and icon sets to be registered using a trusted HTML string. Fixes #3132.
1 parent edb57f9 commit bad81e5

File tree

7 files changed

+467
-83
lines changed

7 files changed

+467
-83
lines changed

src/demo-app/icon/assets/core-icon-set.svg

Lines changed: 119 additions & 33 deletions
Loading

src/demo-app/icon/icon-demo.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@
99
<mat-icon svgIcon="thumb-up"></mat-icon>
1010
</p>
1111

12+
<p>
13+
From inline template with MatIconProvider:
14+
<mat-icon svgIcon="bike" class="green"></mat-icon>
15+
<mat-icon svgIcon="bike"></mat-icon>
16+
</p>
17+
1218
<p>
1319
Mirrored in RTL:
1420
<mat-icon class="mat-icon-rtl-mirror green" svgIcon="thumb-up"></mat-icon>
1521
<mat-icon class="mat-icon-rtl-mirror" svgIcon="thumb-up"></mat-icon>
22+
<mat-icon class="mat-icon-rtl-mirror green" svgIcon="bike"></mat-icon>
23+
<mat-icon class="mat-icon-rtl-mirror" svgIcon="bike"></mat-icon>
1624
</p>
1725

1826
<p>
@@ -22,6 +30,13 @@
2230
<mat-icon svgIcon="core:alarm"></mat-icon>
2331
</p>
2432

33+
<p>
34+
From inline icon set:
35+
<mat-icon svgIcon="core-inline:account-balance"></mat-icon>
36+
<mat-icon svgIcon="core-inline:account-balance-wallet"></mat-icon>
37+
<mat-icon svgIcon="core-inline:account-box"></mat-icon>
38+
</p>
39+
2540
<p>
2641
Ligature from Material Icons font:
2742
<mat-icon>home</mat-icon>

src/demo-app/icon/icon-demo.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,47 @@ export class IconDemo {
2222
iconRegistry
2323
.addSvgIcon('thumb-up',
2424
sanitizer.bypassSecurityTrustResourceUrl('/icon/assets/thumbup-icon.svg'))
25+
.addSvgIconLiteral('bike',
26+
sanitizer.bypassSecurityTrustHtml(BIKE_ICON))
2527
.addSvgIconSetInNamespace('core',
2628
sanitizer.bypassSecurityTrustResourceUrl('/icon/assets/core-icon-set.svg'))
29+
.addSvgIconSetLiteralInNamespace('core-inline',
30+
sanitizer.bypassSecurityTrustHtml(INLINE_ICON_SET))
2731
.registerFontClassAlias('fontawesome', 'fa');
2832
}
2933
}
34+
35+
const BIKE_ICON = `
36+
<svg xmlns="http://www.w3.org/2000/svg">
37+
<path d="M0 0h24v24H0z" fill="none"/>
38+
<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 ` +
39+
`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 ` +
40+
`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 ` +
41+
`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 ` +
42+
`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 ` +
43+
`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 ` +
44+
`3.5-3.5 3.5z"/>
45+
</svg>
46+
`;
47+
48+
const INLINE_ICON_SET = `
49+
<svg>
50+
<defs>
51+
<svg id="account-balance">
52+
<path d="M4 10v7h3v-7H4zm6 0v7h3v-7h-3zM2 22h19v-3H2v3zm14-12v7h3v-` +
53+
`7h-3zm-4.5-9L2 6v2h19V6l-9.5-5z"/>
54+
</svg>
55+
<svg id="account-balance-wallet">
56+
<path d="M21 18v1c0 1.1-.9 2-2 2H5c-1.11 0-2-.9-2-2V5c0-1.1.89-2 2-` +
57+
`2h14c1.1 0 2 .9 2 2v1h-9c-1.11 0-2 .9-2 2v8c0 1.1.89 2 2 2h9zm-9` +
58+
`-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"
59+
/>
60+
</svg>
61+
<svg id="account-box">
62+
<path d="M3 5v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H` +
63+
`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-` +
64+
`3 3 1.34 3 3zm-9 8c0-2 4-3.1 6-3.1s6 1.1 6 3.1v1H6v-1z"/>
65+
</svg>
66+
</defs>
67+
</svg>
68+
`;

src/lib/icon/icon-registry.ts

Lines changed: 114 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
SecurityContext,
1717
SkipSelf,
1818
} from '@angular/core';
19-
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
19+
import {DomSanitizer, SafeResourceUrl, SafeHtml} from '@angular/platform-browser';
2020
import {forkJoin, Observable, of as observableOf, throwError as observableThrow} from 'rxjs';
2121
import {catchError, finalize, map, share, tap} from 'rxjs/operators';
2222

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

56+
/**
57+
* Returns an exception to be thrown when a HTML string couldn't be sanitized.
58+
* @param literal HTML that was attempted to be sanitized.
59+
* @docs-private
60+
*/
61+
export function getMatIconFailedToSanitizeLiteralError(literal: SafeHtml): Error {
62+
return Error(`The literal provided to MatIconRegistry was not trusted as safe HTML by ` +
63+
`Angular's DomSanitizer. Attempted literal was "${literal}".`);
64+
}
65+
66+
5667
/**
5768
* Configuration for an icon, including the URL and possibly the cached SVG element.
5869
* @docs-private
5970
*/
6071
class SvgIconConfig {
61-
svgElement: SVGElement | null = null;
62-
constructor(public url: SafeResourceUrl) { }
72+
url: SafeResourceUrl | null;
73+
svgElement: SVGElement | null;
74+
75+
constructor(url: SafeResourceUrl);
76+
constructor(svgElement: SVGElement);
77+
constructor(data: SafeResourceUrl | SVGElement) {
78+
// Note that we can't use `instanceof SVGElement` here,
79+
// because it'll break during server-side rendering.
80+
if (!!(data as any).nodeName) {
81+
this.svgElement = data as SVGElement;
82+
} else {
83+
this.url = data as SafeResourceUrl;
84+
}
85+
}
6386
}
6487

6588
/**
@@ -116,16 +139,40 @@ export class MatIconRegistry {
116139
return this.addSvgIconInNamespace('', iconName, url);
117140
}
118141

142+
/**
143+
* Registers an icon using an HTML string in the default namespace.
144+
* @param iconName Name under which the icon should be registered.
145+
* @param literal SVG source of the icon.
146+
*/
147+
addSvgIconLiteral(iconName: string, literal: SafeHtml): this {
148+
return this.addSvgIconLiteralInNamespace('', iconName, literal);
149+
}
150+
119151
/**
120152
* Registers an icon by URL in the specified namespace.
121153
* @param namespace Namespace in which the icon should be registered.
122154
* @param iconName Name under which the icon should be registered.
123155
* @param url
124156
*/
125157
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl): this {
126-
const key = iconKey(namespace, iconName);
127-
this._svgIconConfigs.set(key, new SvgIconConfig(url));
128-
return this;
158+
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url));
159+
}
160+
161+
/**
162+
* Registers an icon using an HTML string in the specified namespace.
163+
* @param namespace Namespace in which the icon should be registered.
164+
* @param iconName Name under which the icon should be registered.
165+
* @param literal SVG source of the icon.
166+
*/
167+
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml): this {
168+
const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal);
169+
170+
if (!sanitizedLiteral) {
171+
throw getMatIconFailedToSanitizeLiteralError(literal);
172+
}
173+
174+
const svgElement = this._createSvgElementForSingleIcon(sanitizedLiteral);
175+
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(svgElement));
129176
}
130177

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

186+
/**
187+
* Registers an icon set using an HTML string in the default namespace.
188+
* @param literal SVG source of the icon set.
189+
*/
190+
addSvgIconSetLiteral(literal: SafeHtml): this {
191+
return this.addSvgIconSetLiteralInNamespace('', literal);
192+
}
193+
139194
/**
140195
* Registers an icon set by URL in the specified namespace.
141196
* @param namespace Namespace in which to register the icon set.
142197
* @param url
143198
*/
144199
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl): this {
145-
const config = new SvgIconConfig(url);
146-
const configNamespace = this._iconSetConfigs.get(namespace);
200+
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(url));
201+
}
147202

148-
if (configNamespace) {
149-
configNamespace.push(config);
150-
} else {
151-
this._iconSetConfigs.set(namespace, [config]);
203+
/**
204+
* Registers an icon set using an HTML string in the specified namespace.
205+
* @param namespace Namespace in which to register the icon set.
206+
* @param literal SVG source of the icon set.
207+
*/
208+
addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml): this {
209+
const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal);
210+
211+
if (!sanitizedLiteral) {
212+
throw getMatIconFailedToSanitizeLiteralError(literal);
152213
}
153-
return this;
214+
215+
const svgElement = this._svgElementFromString(sanitizedLiteral);
216+
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement));
154217
}
155218

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

207270
if (!url) {
208-
throw getMatIconFailedToSanitizeError(safeUrl);
271+
throw getMatIconFailedToSanitizeUrlError(safeUrl);
209272
}
210273

211-
let cachedIcon = this._cachedIconsByUrl.get(url);
274+
const cachedIcon = this._cachedIconsByUrl.get(url);
212275

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

532+
if (safeUrl == null) {
533+
throw Error(`Cannot fetch icon from URL "${safeUrl}".`);
534+
}
535+
469536
const url = this._sanitizer.sanitize(SecurityContext.RESOURCE_URL, safeUrl);
470537

471538
if (!url) {
472-
throw getMatIconFailedToSanitizeError(safeUrl);
539+
throw getMatIconFailedToSanitizeUrlError(safeUrl);
473540
}
474541

475542
// Store in-progress fetches to avoid sending a duplicate request for a URL when there is
@@ -491,6 +558,34 @@ export class MatIconRegistry {
491558
this._inProgressUrlFetches.set(url, req);
492559
return req;
493560
}
561+
562+
/**
563+
* Registers an icon config by name in the specified namespace.
564+
* @param namespace Namespace in which to register the icon config.
565+
* @param iconName Name under which to register the config.
566+
* @param config Config to be registered.
567+
*/
568+
private _addSvgIconConfig(namespace: string, iconName: string, config: SvgIconConfig): this {
569+
this._svgIconConfigs.set(iconKey(namespace, iconName), config);
570+
return this;
571+
}
572+
573+
/**
574+
* Registers an icon set config in the specified namespace.
575+
* @param namespace Namespace in which to register the icon config.
576+
* @param config Config to be registered.
577+
*/
578+
private _addSvgIconSetConfig(namespace: string, config: SvgIconConfig): this {
579+
const configNamespace = this._iconSetConfigs.get(namespace);
580+
581+
if (configNamespace) {
582+
configNamespace.push(config);
583+
} else {
584+
this._iconSetConfigs.set(namespace, [config]);
585+
}
586+
587+
return this;
588+
}
494589
}
495590

496591
/** @docs-private */

src/lib/icon/icon.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ icon fonts and SVG icons, but not bitmap-based formats (png, jpg, etc.).
55

66
### Registering icons
77

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

1112
### Font icons with ligatures
1213

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

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

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

5455
#### Named icons
5556

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

6163
#### Icon sets
6264

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

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

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

0 commit comments

Comments
 (0)