Skip to content

Commit 1fb03ce

Browse files
authored
feat(material/icon): add support for registry resolver function (#21431)
Currently all of the ways to register icons (e.g. via URL or literal) require the developer to know the list of icons ahead of time or to fetch more icons than the user needs. These changes aim to address this issue by adding the ability to register an icon resolver function which will construct the URL at runtime, depending on the requested icon. Fixes #18607.
1 parent 1f18750 commit 1fb03ce

File tree

5 files changed

+187
-7
lines changed

5 files changed

+187
-7
lines changed

src/material/icon/icon-registry.ts

+62-5
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ export interface IconOptions {
7474
withCredentials?: boolean;
7575
}
7676

77+
/**
78+
* Function that will be invoked by the icon registry when trying to resolve the
79+
* URL from which to fetch an icon. The returned URL will be used to make a request for the icon.
80+
*/
81+
export type IconResolver = (name: string, namespace: string) =>
82+
(SafeResourceUrl | SafeResourceUrlWithIconOptions | null);
83+
84+
/** Object that specifies a URL from which to fetch an icon and the options to use for it. */
85+
export interface SafeResourceUrlWithIconOptions {
86+
url: SafeResourceUrl;
87+
options: IconOptions;
88+
}
89+
7790
/**
7891
* Configuration for an icon, including the URL and possibly the cached SVG element.
7992
* @docs-private
@@ -121,6 +134,9 @@ export class MatIconRegistry implements OnDestroy {
121134
/** Map from font identifiers to their CSS class names. Used for icon fonts. */
122135
private _fontCssClassesByAlias = new Map<string, string>();
123136

137+
/** Registered icon resolver functions. */
138+
private _resolvers: IconResolver[] = [];
139+
124140
/**
125141
* The CSS class to apply when an `<mat-icon>` component has no icon name, url, or font specified.
126142
* The default 'material-icons' value assumes that the material icon font has been loaded as
@@ -165,6 +181,19 @@ export class MatIconRegistry implements OnDestroy {
165181
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url, null, options));
166182
}
167183

184+
/**
185+
* Registers an icon resolver function with the registry. The function will be invoked with the
186+
* name and namespace of an icon when the registry tries to resolve the URL from which to fetch
187+
* the icon. The resolver is expected to return a `SafeResourceUrl` that points to the icon,
188+
* an object with the icon URL and icon options, or `null` if the icon is not supported. Resolvers
189+
* will be invoked in the order in which they have been registered.
190+
* @param resolver Resolver function to be registered.
191+
*/
192+
addSvgIconResolver(resolver: IconResolver): this {
193+
this._resolvers.push(resolver);
194+
return this;
195+
}
196+
168197
/**
169198
* Registers an icon using an HTML string in the specified namespace.
170199
* @param namespace Namespace in which the icon should be registered.
@@ -301,11 +330,19 @@ export class MatIconRegistry implements OnDestroy {
301330
* @param namespace Namespace in which to look for the icon.
302331
*/
303332
getNamedSvgIcon(name: string, namespace: string = ''): Observable<SVGElement> {
304-
// Return (copy of) cached icon if possible.
305333
const key = iconKey(namespace, name);
306-
const config = this._svgIconConfigs.get(key);
334+
let config = this._svgIconConfigs.get(key);
335+
336+
// Return (copy of) cached icon if possible.
337+
if (config) {
338+
return this._getSvgFromConfig(config);
339+
}
340+
341+
// Otherwise try to resolve the config from one of the resolver functions.
342+
config = this._getIconConfigFromResolvers(namespace, name);
307343

308344
if (config) {
345+
this._svgIconConfigs.set(key, config);
309346
return this._getSvgFromConfig(config);
310347
}
311348

@@ -320,9 +357,10 @@ export class MatIconRegistry implements OnDestroy {
320357
}
321358

322359
ngOnDestroy() {
323-
this._svgIconConfigs.clear();
324-
this._iconSetConfigs.clear();
325-
this._cachedIconsByUrl.clear();
360+
this._resolvers = [];
361+
this._svgIconConfigs.clear();
362+
this._iconSetConfigs.clear();
363+
this._cachedIconsByUrl.clear();
326364
}
327365

328366
/**
@@ -623,6 +661,21 @@ export class MatIconRegistry implements OnDestroy {
623661

624662
return config.svgElement;
625663
}
664+
665+
/** Tries to create an icon config through the registered resolver functions. */
666+
private _getIconConfigFromResolvers(namespace: string, name: string): SvgIconConfig | undefined {
667+
for (let i = 0; i < this._resolvers.length; i++) {
668+
const result = this._resolvers[i](name, namespace);
669+
670+
if (result) {
671+
return isSafeUrlWithOptions(result) ?
672+
new SvgIconConfig(result.url, null, result.options) :
673+
new SvgIconConfig(result, null);
674+
}
675+
}
676+
677+
return undefined;
678+
}
626679
}
627680

628681
/** @docs-private */
@@ -658,3 +711,7 @@ function cloneSvg(svg: SVGElement): SVGElement {
658711
function iconKey(namespace: string, name: string) {
659712
return namespace + ':' + name;
660713
}
714+
715+
function isSafeUrlWithOptions(value: any): value is SafeResourceUrlWithIconOptions {
716+
return !!(value.url && value.options);
717+
}

src/material/icon/icon.spec.ts

+112
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,118 @@ describe('MatIcon', () => {
10011001

10021002
});
10031003

1004+
describe('Icons resolved through a resolver function', () => {
1005+
it('should resolve icons through a resolver function', fakeAsync(() => {
1006+
iconRegistry.addSvgIconResolver(name => {
1007+
if (name === 'fluffy') {
1008+
return trustUrl('cat.svg');
1009+
} else if (name === 'fido') {
1010+
return trustUrl('dog.svg');
1011+
} else if (name === 'felix') {
1012+
return {url: trustUrl('auth-cat.svg'), options: {withCredentials: true}};
1013+
}
1014+
return null;
1015+
});
1016+
1017+
const fixture = TestBed.createComponent(IconFromSvgName);
1018+
let svgElement: SVGElement;
1019+
let testRequest: TestRequest;
1020+
const testComponent = fixture.componentInstance;
1021+
const iconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
1022+
1023+
testComponent.iconName = 'fido';
1024+
fixture.detectChanges();
1025+
http.expectOne('dog.svg').flush(FAKE_SVGS.dog);
1026+
svgElement = verifyAndGetSingleSvgChild(iconElement);
1027+
verifyPathChildElement(svgElement, 'woof');
1028+
1029+
// Change the icon, and the SVG element should be replaced.
1030+
testComponent.iconName = 'fluffy';
1031+
fixture.detectChanges();
1032+
http.expectOne('cat.svg').flush(FAKE_SVGS.cat);
1033+
svgElement = verifyAndGetSingleSvgChild(iconElement);
1034+
verifyPathChildElement(svgElement, 'meow');
1035+
1036+
// Using an icon from a previously loaded URL should not cause another HTTP request.
1037+
testComponent.iconName = 'fido';
1038+
fixture.detectChanges();
1039+
http.expectNone('dog.svg');
1040+
svgElement = verifyAndGetSingleSvgChild(iconElement);
1041+
verifyPathChildElement(svgElement, 'woof');
1042+
1043+
// Change icon to one that needs credentials during fetch.
1044+
testComponent.iconName = 'felix';
1045+
fixture.detectChanges();
1046+
testRequest = http.expectOne('auth-cat.svg');
1047+
expect(testRequest.request.withCredentials).toBeTrue();
1048+
testRequest.flush(FAKE_SVGS.cat);
1049+
svgElement = verifyAndGetSingleSvgChild(iconElement);
1050+
verifyPathChildElement(svgElement, 'meow');
1051+
1052+
// Assert that a registered icon can be looked-up by url.
1053+
iconRegistry.getSvgIconFromUrl(trustUrl('cat.svg')).subscribe(element => {
1054+
verifyPathChildElement(element, 'meow');
1055+
});
1056+
1057+
tick();
1058+
}));
1059+
1060+
it('should fall back to second resolver if the first one returned null', fakeAsync(() => {
1061+
iconRegistry
1062+
.addSvgIconResolver(() => null)
1063+
.addSvgIconResolver(name => name === 'fido' ? trustUrl('dog.svg') : null);
1064+
1065+
const fixture = TestBed.createComponent(IconFromSvgName);
1066+
const iconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
1067+
1068+
fixture.componentInstance.iconName = 'fido';
1069+
fixture.detectChanges();
1070+
http.expectOne('dog.svg').flush(FAKE_SVGS.dog);
1071+
verifyPathChildElement(verifyAndGetSingleSvgChild(iconElement), 'woof');
1072+
tick();
1073+
}));
1074+
1075+
it('should be able to set the viewBox when resolving an icon with a function', fakeAsync(() => {
1076+
iconRegistry.addSvgIconResolver(name => {
1077+
if (name === 'fluffy') {
1078+
return {url: trustUrl('cat.svg'), options: {viewBox: '0 0 27 27'}};
1079+
} else if (name === 'fido') {
1080+
return {url: trustUrl('dog.svg'), options: {viewBox: '0 0 43 43'}};
1081+
}
1082+
return null;
1083+
});
1084+
1085+
const fixture = TestBed.createComponent(IconFromSvgName);
1086+
let svgElement: SVGElement;
1087+
const testComponent = fixture.componentInstance;
1088+
const iconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
1089+
1090+
testComponent.iconName = 'fido';
1091+
fixture.detectChanges();
1092+
http.expectOne('dog.svg').flush(FAKE_SVGS.dog);
1093+
svgElement = verifyAndGetSingleSvgChild(iconElement);
1094+
expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43');
1095+
1096+
// Change the icon, and the SVG element should be replaced.
1097+
testComponent.iconName = 'fluffy';
1098+
fixture.detectChanges();
1099+
http.expectOne('cat.svg').flush(FAKE_SVGS.cat);
1100+
svgElement = verifyAndGetSingleSvgChild(iconElement);
1101+
expect(svgElement.getAttribute('viewBox')).toBe('0 0 27 27');
1102+
}));
1103+
1104+
it('should throw an error when the resolver returns an untrusted URL', () => {
1105+
iconRegistry.addSvgIconResolver(() => 'not-trusted.svg');
1106+
1107+
expect(() => {
1108+
const fixture = TestBed.createComponent(IconFromSvgName);
1109+
fixture.componentInstance.iconName = 'fluffy';
1110+
fixture.detectChanges();
1111+
}).toThrowError(/unsafe value used in a resource URL context/);
1112+
});
1113+
1114+
});
1115+
10041116
it('should handle assigning an icon through the setter', fakeAsync(() => {
10051117
iconRegistry.addSvgIconLiteral('fido', trustHtml(FAKE_SVGS.dog));
10061118

src/material/icon/testing/fake-icon-registry.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ import {Injectable, NgModule, OnDestroy} from '@angular/core';
1010
import {MatIconRegistry} from '@angular/material/icon';
1111
import {Observable, of as observableOf} from 'rxjs';
1212

13-
// tslint:disable:no-any Impossible to tell param types.
1413
type PublicApi<T> = {
1514
[K in keyof T]: T[K] extends (...x: any[]) => T ? (...x: any[]) => PublicApi<T> : T[K]
1615
};
17-
// tslint:enable:no-any
1816

1917
/**
2018
* A null icon registry that must be imported to allow disabling of custom
@@ -78,6 +76,10 @@ export class FakeMatIconRegistry implements PublicApi<MatIconRegistry>, OnDestro
7876
return this;
7977
}
8078

79+
addSvgIconResolver(): this {
80+
return this;
81+
}
82+
8183
ngOnDestroy() { }
8284

8385
private _generateEmptySvg(): SVGElement {

tools/public_api_guard/material/icon.d.ts

+8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface IconOptions {
1919
withCredentials?: boolean;
2020
}
2121

22+
export declare type IconResolver = (name: string, namespace: string) => (SafeResourceUrl | SafeResourceUrlWithIconOptions | null);
23+
2224
export declare const MAT_ICON_LOCATION: InjectionToken<MatIconLocation>;
2325

2426
export declare function MAT_ICON_LOCATION_FACTORY(): MatIconLocation;
@@ -59,6 +61,7 @@ export declare class MatIconRegistry implements OnDestroy {
5961
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl, options?: IconOptions): this;
6062
addSvgIconLiteral(iconName: string, literal: SafeHtml, options?: IconOptions): this;
6163
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml, options?: IconOptions): this;
64+
addSvgIconResolver(resolver: IconResolver): this;
6265
addSvgIconSet(url: SafeResourceUrl, options?: IconOptions): this;
6366
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl, options?: IconOptions): this;
6467
addSvgIconSetLiteral(literal: SafeHtml, options?: IconOptions): this;
@@ -73,3 +76,8 @@ export declare class MatIconRegistry implements OnDestroy {
7376
static ɵfac: i0.ɵɵFactoryDef<MatIconRegistry, [{ optional: true; }, null, { optional: true; }, null]>;
7477
static ɵprov: i0.ɵɵInjectableDef<MatIconRegistry>;
7578
}
79+
80+
export interface SafeResourceUrlWithIconOptions {
81+
options: IconOptions;
82+
url: SafeResourceUrl;
83+
}

tools/public_api_guard/material/icon/testing.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export declare class FakeMatIconRegistry implements PublicApi<MatIconRegistry>,
33
addSvgIconInNamespace(): this;
44
addSvgIconLiteral(): this;
55
addSvgIconLiteralInNamespace(): this;
6+
addSvgIconResolver(): this;
67
addSvgIconSet(): this;
78
addSvgIconSetInNamespace(): this;
89
addSvgIconSetLiteral(): this;

0 commit comments

Comments
 (0)