Skip to content

Commit 3638886

Browse files
crisbetojelbourn
authored andcommitted
feat(icon): allow viewBox to be configured when registering icons (#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 #2981. Fixes #16293.
1 parent 3bd160b commit 3638886

File tree

3 files changed

+129
-37
lines changed

3 files changed

+129
-37
lines changed

src/material/icon/icon-registry.ts

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export function getMatIconFailedToSanitizeLiteralError(literal: SafeHtml): Error
6464
`Angular's DomSanitizer. Attempted literal was "${literal}".`);
6565
}
6666

67+
/** Options that can be used to configure how an icon or the icons in an icon set are presented. */
68+
export interface IconOptions {
69+
/** View box to set on the icon. */
70+
viewBox?: string;
71+
}
6772

6873
/**
6974
* Configuration for an icon, including the URL and possibly the cached SVG element.
@@ -73,9 +78,9 @@ class SvgIconConfig {
7378
url: SafeResourceUrl | null;
7479
svgElement: SVGElement | null;
7580

76-
constructor(url: SafeResourceUrl);
77-
constructor(svgElement: SVGElement);
78-
constructor(data: SafeResourceUrl | SVGElement) {
81+
constructor(url: SafeResourceUrl, options?: IconOptions);
82+
constructor(svgElement: SVGElement, options?: IconOptions);
83+
constructor(data: SafeResourceUrl | SVGElement, public options?: IconOptions) {
7984
// Note that we can't use `instanceof SVGElement` here,
8085
// because it'll break during server-side rendering.
8186
if (!!(data as any).nodeName) {
@@ -136,17 +141,17 @@ export class MatIconRegistry implements OnDestroy {
136141
* @param iconName Name under which the icon should be registered.
137142
* @param url
138143
*/
139-
addSvgIcon(iconName: string, url: SafeResourceUrl): this {
140-
return this.addSvgIconInNamespace('', iconName, url);
144+
addSvgIcon(iconName: string, url: SafeResourceUrl, options?: IconOptions): this {
145+
return this.addSvgIconInNamespace('', iconName, url, options);
141146
}
142147

143148
/**
144149
* Registers an icon using an HTML string in the default namespace.
145150
* @param iconName Name under which the icon should be registered.
146151
* @param literal SVG source of the icon.
147152
*/
148-
addSvgIconLiteral(iconName: string, literal: SafeHtml): this {
149-
return this.addSvgIconLiteralInNamespace('', iconName, literal);
153+
addSvgIconLiteral(iconName: string, literal: SafeHtml, options?: IconOptions): this {
154+
return this.addSvgIconLiteralInNamespace('', iconName, literal, options);
150155
}
151156

152157
/**
@@ -155,8 +160,9 @@ export class MatIconRegistry implements OnDestroy {
155160
* @param iconName Name under which the icon should be registered.
156161
* @param url
157162
*/
158-
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl): this {
159-
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url));
163+
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl,
164+
options?: IconOptions): this {
165+
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url, options));
160166
}
161167

162168
/**
@@ -165,56 +171,58 @@ export class MatIconRegistry implements OnDestroy {
165171
* @param iconName Name under which the icon should be registered.
166172
* @param literal SVG source of the icon.
167173
*/
168-
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml): this {
174+
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml,
175+
options?: IconOptions): this {
169176
const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal);
170177

171178
if (!sanitizedLiteral) {
172179
throw getMatIconFailedToSanitizeLiteralError(literal);
173180
}
174181

175-
const svgElement = this._createSvgElementForSingleIcon(sanitizedLiteral);
176-
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(svgElement));
182+
const svgElement = this._createSvgElementForSingleIcon(sanitizedLiteral, options);
183+
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(svgElement, options));
177184
}
178185

179186
/**
180187
* Registers an icon set by URL in the default namespace.
181188
* @param url
182189
*/
183-
addSvgIconSet(url: SafeResourceUrl): this {
184-
return this.addSvgIconSetInNamespace('', url);
190+
addSvgIconSet(url: SafeResourceUrl, options?: IconOptions): this {
191+
return this.addSvgIconSetInNamespace('', url, options);
185192
}
186193

187194
/**
188195
* Registers an icon set using an HTML string in the default namespace.
189196
* @param literal SVG source of the icon set.
190197
*/
191-
addSvgIconSetLiteral(literal: SafeHtml): this {
192-
return this.addSvgIconSetLiteralInNamespace('', literal);
198+
addSvgIconSetLiteral(literal: SafeHtml, options?: IconOptions): this {
199+
return this.addSvgIconSetLiteralInNamespace('', literal, options);
193200
}
194201

195202
/**
196203
* Registers an icon set by URL in the specified namespace.
197204
* @param namespace Namespace in which to register the icon set.
198205
* @param url
199206
*/
200-
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl): this {
201-
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(url));
207+
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl, options?: IconOptions): this {
208+
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(url, options));
202209
}
203210

204211
/**
205212
* Registers an icon set using an HTML string in the specified namespace.
206213
* @param namespace Namespace in which to register the icon set.
207214
* @param literal SVG source of the icon set.
208215
*/
209-
addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml): this {
216+
addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml,
217+
options?: IconOptions): this {
210218
const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal);
211219

212220
if (!sanitizedLiteral) {
213221
throw getMatIconFailedToSanitizeLiteralError(literal);
214222
}
215223

216224
const svgElement = this._svgElementFromString(sanitizedLiteral);
217-
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement));
225+
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement, options));
218226
}
219227

220228
/**
@@ -395,7 +403,7 @@ export class MatIconRegistry implements OnDestroy {
395403
for (let i = iconSetConfigs.length - 1; i >= 0; i--) {
396404
const config = iconSetConfigs[i];
397405
if (config.svgElement) {
398-
const foundIcon = this._extractSvgIconFromSet(config.svgElement, iconName);
406+
const foundIcon = this._extractSvgIconFromSet(config.svgElement, iconName, config.options);
399407
if (foundIcon) {
400408
return foundIcon;
401409
}
@@ -410,7 +418,7 @@ export class MatIconRegistry implements OnDestroy {
410418
*/
411419
private _loadSvgIconFromConfig(config: SvgIconConfig): Observable<SVGElement> {
412420
return this._fetchUrl(config.url)
413-
.pipe(map(svgText => this._createSvgElementForSingleIcon(svgText)));
421+
.pipe(map(svgText => this._createSvgElementForSingleIcon(svgText, config.options)));
414422
}
415423

416424
/**
@@ -437,9 +445,9 @@ export class MatIconRegistry implements OnDestroy {
437445
/**
438446
* Creates a DOM element from the given SVG string, and adds default attributes.
439447
*/
440-
private _createSvgElementForSingleIcon(responseText: string): SVGElement {
448+
private _createSvgElementForSingleIcon(responseText: string, options?: IconOptions): SVGElement {
441449
const svg = this._svgElementFromString(responseText);
442-
this._setSvgAttributes(svg);
450+
this._setSvgAttributes(svg, options);
443451
return svg;
444452
}
445453

@@ -448,7 +456,8 @@ export class MatIconRegistry implements OnDestroy {
448456
* tag matches the specified name. If found, copies the nested element to a new SVG element and
449457
* returns it. Returns null if no matching element is found.
450458
*/
451-
private _extractSvgIconFromSet(iconSet: SVGElement, iconName: string): SVGElement | null {
459+
private _extractSvgIconFromSet(iconSet: SVGElement, iconName: string,
460+
options?: IconOptions): SVGElement | null {
452461
// Use the `id="iconName"` syntax in order to escape special
453462
// characters in the ID (versus using the #iconName syntax).
454463
const iconSource = iconSet.querySelector(`[id="${iconName}"]`);
@@ -465,14 +474,14 @@ export class MatIconRegistry implements OnDestroy {
465474
// If the icon node is itself an <svg> node, clone and return it directly. If not, set it as
466475
// the content of a new <svg> node.
467476
if (iconElement.nodeName.toLowerCase() === 'svg') {
468-
return this._setSvgAttributes(iconElement as SVGElement);
477+
return this._setSvgAttributes(iconElement as SVGElement, options);
469478
}
470479

471480
// If the node is a <symbol>, it won't be rendered so we have to convert it into <svg>. Note
472481
// that the same could be achieved by referring to it via <use href="#id">, however the <use>
473482
// tag is problematic on Firefox, because it needs to include the current page path.
474483
if (iconElement.nodeName.toLowerCase() === 'symbol') {
475-
return this._setSvgAttributes(this._toSvgElement(iconElement));
484+
return this._setSvgAttributes(this._toSvgElement(iconElement), options);
476485
}
477486

478487
// createElement('SVG') doesn't work as expected; the DOM ends up with
@@ -484,7 +493,7 @@ export class MatIconRegistry implements OnDestroy {
484493
// Clone the node so we don't remove it from the parent icon set element.
485494
svg.appendChild(iconElement);
486495

487-
return this._setSvgAttributes(svg);
496+
return this._setSvgAttributes(svg, options);
488497
}
489498

490499
/**
@@ -520,12 +529,17 @@ export class MatIconRegistry implements OnDestroy {
520529
/**
521530
* Sets the default attributes for an SVG element to be used as an icon.
522531
*/
523-
private _setSvgAttributes(svg: SVGElement): SVGElement {
532+
private _setSvgAttributes(svg: SVGElement, options?: IconOptions): SVGElement {
524533
svg.setAttribute('fit', '');
525534
svg.setAttribute('height', '100%');
526535
svg.setAttribute('width', '100%');
527536
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
528537
svg.setAttribute('focusable', 'false'); // Disable IE11 default behavior to make SVGs focusable.
538+
539+
if (options && options.viewBox) {
540+
svg.setAttribute('viewBox', options.viewBox);
541+
}
542+
529543
return svg;
530544
}
531545

src/material/icon/icon.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,29 @@ describe('MatIcon', () => {
215215
tick();
216216
}));
217217

218+
it('should be able to set the viewBox when registering a single SVG icon', fakeAsync(() => {
219+
iconRegistry.addSvgIcon('fluffy', trustUrl('cat.svg'), {viewBox: '0 0 27 27'});
220+
iconRegistry.addSvgIcon('fido', trustUrl('dog.svg'), {viewBox: '0 0 43 43'});
221+
222+
let fixture = TestBed.createComponent(IconFromSvgName);
223+
let svgElement: SVGElement;
224+
const testComponent = fixture.componentInstance;
225+
const iconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
226+
227+
testComponent.iconName = 'fido';
228+
fixture.detectChanges();
229+
http.expectOne('dog.svg').flush(FAKE_SVGS.dog);
230+
svgElement = verifyAndGetSingleSvgChild(iconElement);
231+
expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43');
232+
233+
// Change the icon, and the SVG element should be replaced.
234+
testComponent.iconName = 'fluffy';
235+
fixture.detectChanges();
236+
http.expectOne('cat.svg').flush(FAKE_SVGS.cat);
237+
svgElement = verifyAndGetSingleSvgChild(iconElement);
238+
expect(svgElement.getAttribute('viewBox')).toBe('0 0 27 27');
239+
}));
240+
218241
it('should throw an error when using an untrusted icon url', () => {
219242
iconRegistry.addSvgIcon('fluffy', 'farm-set-1.svg');
220243

@@ -449,6 +472,22 @@ describe('MatIcon', () => {
449472
}).not.toThrow();
450473
});
451474

475+
it('should be able to configure the viewBox for the icon set', () => {
476+
iconRegistry.addSvgIconSet(trustUrl('arrow-set.svg'), {viewBox: '0 0 43 43'});
477+
478+
const fixture = TestBed.createComponent(IconFromSvgName);
479+
const testComponent = fixture.componentInstance;
480+
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
481+
let svgElement: any;
482+
483+
testComponent.iconName = 'left-arrow';
484+
fixture.detectChanges();
485+
http.expectOne('arrow-set.svg').flush(FAKE_SVGS.arrows);
486+
svgElement = verifyAndGetSingleSvgChild(matIconElement);
487+
488+
expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43');
489+
});
490+
452491
it('should remove the SVG element from the DOM when the binding is cleared', () => {
453492
iconRegistry.addSvgIconSet(trustUrl('arrow-set.svg'));
454493

@@ -518,6 +557,26 @@ describe('MatIcon', () => {
518557
tick();
519558
}));
520559

560+
it('should be able to configure the icon viewBox', fakeAsync(() => {
561+
iconRegistry.addSvgIconLiteral('fluffy', trustHtml(FAKE_SVGS.cat), {viewBox: '0 0 43 43'});
562+
iconRegistry.addSvgIconLiteral('fido', trustHtml(FAKE_SVGS.dog), {viewBox: '0 0 27 27'});
563+
564+
let fixture = TestBed.createComponent(IconFromSvgName);
565+
let svgElement: SVGElement;
566+
const testComponent = fixture.componentInstance;
567+
const iconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
568+
569+
testComponent.iconName = 'fido';
570+
fixture.detectChanges();
571+
svgElement = verifyAndGetSingleSvgChild(iconElement);
572+
expect(svgElement.getAttribute('viewBox')).toBe('0 0 27 27');
573+
574+
testComponent.iconName = 'fluffy';
575+
fixture.detectChanges();
576+
svgElement = verifyAndGetSingleSvgChild(iconElement);
577+
expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43');
578+
}));
579+
521580
it('should throw an error when using untrusted HTML', () => {
522581
// Stub out console.warn so we don't pollute our logs with Angular's warnings.
523582
// Jasmine will tear the spy down at the end of the test.
@@ -631,6 +690,21 @@ describe('MatIcon', () => {
631690
expect(svgElement.getAttribute('viewBox')).toBeFalsy();
632691
});
633692

693+
it('should be able to configure the viewBox for the icon set', () => {
694+
iconRegistry.addSvgIconSetLiteral(trustHtml(FAKE_SVGS.arrows), {viewBox: '0 0 43 43'});
695+
696+
const fixture = TestBed.createComponent(IconFromSvgName);
697+
const testComponent = fixture.componentInstance;
698+
const matIconElement = fixture.debugElement.nativeElement.querySelector('mat-icon');
699+
let svgElement: any;
700+
701+
testComponent.iconName = 'left-arrow';
702+
fixture.detectChanges();
703+
svgElement = verifyAndGetSingleSvgChild(matIconElement);
704+
705+
expect(svgElement.getAttribute('viewBox')).toBe('0 0 43 43');
706+
});
707+
634708
it('should add an extra string to the end of `style` tags inside SVG', fakeAsync(() => {
635709
iconRegistry.addSvgIconLiteral('fido', trustHtml(`
636710
<svg>

tools/public_api_guard/material/icon.d.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export declare const ICON_REGISTRY_PROVIDER: {
1414

1515
export declare function ICON_REGISTRY_PROVIDER_FACTORY(parentRegistry: MatIconRegistry, httpClient: HttpClient, sanitizer: DomSanitizer, document?: any): MatIconRegistry;
1616

17+
export interface IconOptions {
18+
viewBox?: string;
19+
}
20+
1721
export declare const MAT_ICON_LOCATION: InjectionToken<MatIconLocation>;
1822

1923
export declare function MAT_ICON_LOCATION_FACTORY(): MatIconLocation;
@@ -40,14 +44,14 @@ export declare class MatIconModule {
4044

4145
export declare class MatIconRegistry implements OnDestroy {
4246
constructor(_httpClient: HttpClient, _sanitizer: DomSanitizer, document: any);
43-
addSvgIcon(iconName: string, url: SafeResourceUrl): this;
44-
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl): this;
45-
addSvgIconLiteral(iconName: string, literal: SafeHtml): this;
46-
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml): this;
47-
addSvgIconSet(url: SafeResourceUrl): this;
48-
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl): this;
49-
addSvgIconSetLiteral(literal: SafeHtml): this;
50-
addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml): this;
47+
addSvgIcon(iconName: string, url: SafeResourceUrl, options?: IconOptions): this;
48+
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl, options?: IconOptions): this;
49+
addSvgIconLiteral(iconName: string, literal: SafeHtml, options?: IconOptions): this;
50+
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml, options?: IconOptions): this;
51+
addSvgIconSet(url: SafeResourceUrl, options?: IconOptions): this;
52+
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl, options?: IconOptions): this;
53+
addSvgIconSetLiteral(literal: SafeHtml, options?: IconOptions): this;
54+
addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml, options?: IconOptions): this;
5155
classNameForFontAlias(alias: string): string;
5256
getDefaultFontSetClass(): string;
5357
getNamedSvgIcon(name: string, namespace?: string): Observable<SVGElement>;

0 commit comments

Comments
 (0)