Skip to content

Commit 550bbc7

Browse files
committed
feat(icon): allow viewBox to be configured when registering icons
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 22268c3 commit 550bbc7

File tree

3 files changed

+119
-37
lines changed

3 files changed

+119
-37
lines changed

src/material/icon/icon-registry.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ class SvgIconConfig {
7373
url: SafeResourceUrl | null;
7474
svgElement: SVGElement | null;
7575

76-
constructor(url: SafeResourceUrl);
77-
constructor(svgElement: SVGElement);
78-
constructor(data: SafeResourceUrl | SVGElement) {
76+
constructor(url: SafeResourceUrl, viewBox?: string);
77+
constructor(svgElement: SVGElement, viewBox?: string);
78+
constructor(data: SafeResourceUrl | SVGElement, public viewBox?: string) {
7979
// Note that we can't use `instanceof SVGElement` here,
8080
// because it'll break during server-side rendering.
8181
if (!!(data as any).nodeName) {
@@ -136,17 +136,17 @@ export class MatIconRegistry implements OnDestroy {
136136
* @param iconName Name under which the icon should be registered.
137137
* @param url
138138
*/
139-
addSvgIcon(iconName: string, url: SafeResourceUrl): this {
140-
return this.addSvgIconInNamespace('', iconName, url);
139+
addSvgIcon(iconName: string, url: SafeResourceUrl, viewBox?: string): this {
140+
return this.addSvgIconInNamespace('', iconName, url, viewBox);
141141
}
142142

143143
/**
144144
* Registers an icon using an HTML string in the default namespace.
145145
* @param iconName Name under which the icon should be registered.
146146
* @param literal SVG source of the icon.
147147
*/
148-
addSvgIconLiteral(iconName: string, literal: SafeHtml): this {
149-
return this.addSvgIconLiteralInNamespace('', iconName, literal);
148+
addSvgIconLiteral(iconName: string, literal: SafeHtml, viewBox?: string): this {
149+
return this.addSvgIconLiteralInNamespace('', iconName, literal, viewBox);
150150
}
151151

152152
/**
@@ -155,8 +155,9 @@ export class MatIconRegistry implements OnDestroy {
155155
* @param iconName Name under which the icon should be registered.
156156
* @param url
157157
*/
158-
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl): this {
159-
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url));
158+
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl,
159+
viewBox?: string): this {
160+
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(url, viewBox));
160161
}
161162

162163
/**
@@ -165,56 +166,57 @@ export class MatIconRegistry implements OnDestroy {
165166
* @param iconName Name under which the icon should be registered.
166167
* @param literal SVG source of the icon.
167168
*/
168-
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml): this {
169+
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml,
170+
viewBox?: string): this {
169171
const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal);
170172

171173
if (!sanitizedLiteral) {
172174
throw getMatIconFailedToSanitizeLiteralError(literal);
173175
}
174176

175-
const svgElement = this._createSvgElementForSingleIcon(sanitizedLiteral);
176-
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(svgElement));
177+
const svgElement = this._createSvgElementForSingleIcon(sanitizedLiteral, viewBox);
178+
return this._addSvgIconConfig(namespace, iconName, new SvgIconConfig(svgElement, viewBox));
177179
}
178180

179181
/**
180182
* Registers an icon set by URL in the default namespace.
181183
* @param url
182184
*/
183-
addSvgIconSet(url: SafeResourceUrl): this {
184-
return this.addSvgIconSetInNamespace('', url);
185+
addSvgIconSet(url: SafeResourceUrl, viewBox?: string): this {
186+
return this.addSvgIconSetInNamespace('', url, viewBox);
185187
}
186188

187189
/**
188190
* Registers an icon set using an HTML string in the default namespace.
189191
* @param literal SVG source of the icon set.
190192
*/
191-
addSvgIconSetLiteral(literal: SafeHtml): this {
192-
return this.addSvgIconSetLiteralInNamespace('', literal);
193+
addSvgIconSetLiteral(literal: SafeHtml, viewBox?: string): this {
194+
return this.addSvgIconSetLiteralInNamespace('', literal, viewBox);
193195
}
194196

195197
/**
196198
* Registers an icon set by URL in the specified namespace.
197199
* @param namespace Namespace in which to register the icon set.
198200
* @param url
199201
*/
200-
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl): this {
201-
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(url));
202+
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl, viewBox?: string): this {
203+
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(url, viewBox));
202204
}
203205

204206
/**
205207
* Registers an icon set using an HTML string in the specified namespace.
206208
* @param namespace Namespace in which to register the icon set.
207209
* @param literal SVG source of the icon set.
208210
*/
209-
addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml): this {
211+
addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml, viewBox?: string): this {
210212
const sanitizedLiteral = this._sanitizer.sanitize(SecurityContext.HTML, literal);
211213

212214
if (!sanitizedLiteral) {
213215
throw getMatIconFailedToSanitizeLiteralError(literal);
214216
}
215217

216218
const svgElement = this._svgElementFromString(sanitizedLiteral);
217-
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement));
219+
return this._addSvgIconSetConfig(namespace, new SvgIconConfig(svgElement, viewBox));
218220
}
219221

220222
/**
@@ -395,7 +397,7 @@ export class MatIconRegistry implements OnDestroy {
395397
for (let i = iconSetConfigs.length - 1; i >= 0; i--) {
396398
const config = iconSetConfigs[i];
397399
if (config.svgElement) {
398-
const foundIcon = this._extractSvgIconFromSet(config.svgElement, iconName);
400+
const foundIcon = this._extractSvgIconFromSet(config.svgElement, iconName, config.viewBox);
399401
if (foundIcon) {
400402
return foundIcon;
401403
}
@@ -410,7 +412,7 @@ export class MatIconRegistry implements OnDestroy {
410412
*/
411413
private _loadSvgIconFromConfig(config: SvgIconConfig): Observable<SVGElement> {
412414
return this._fetchUrl(config.url)
413-
.pipe(map(svgText => this._createSvgElementForSingleIcon(svgText)));
415+
.pipe(map(svgText => this._createSvgElementForSingleIcon(svgText, config.viewBox)));
414416
}
415417

416418
/**
@@ -437,9 +439,9 @@ export class MatIconRegistry implements OnDestroy {
437439
/**
438440
* Creates a DOM element from the given SVG string, and adds default attributes.
439441
*/
440-
private _createSvgElementForSingleIcon(responseText: string): SVGElement {
442+
private _createSvgElementForSingleIcon(responseText: string, viewBox?: string): SVGElement {
441443
const svg = this._svgElementFromString(responseText);
442-
this._setSvgAttributes(svg);
444+
this._setSvgAttributes(svg, viewBox);
443445
return svg;
444446
}
445447

@@ -448,7 +450,8 @@ export class MatIconRegistry implements OnDestroy {
448450
* tag matches the specified name. If found, copies the nested element to a new SVG element and
449451
* returns it. Returns null if no matching element is found.
450452
*/
451-
private _extractSvgIconFromSet(iconSet: SVGElement, iconName: string): SVGElement | null {
453+
private _extractSvgIconFromSet(iconSet: SVGElement, iconName: string,
454+
viewBox?: string): SVGElement | null {
452455
// Use the `id="iconName"` syntax in order to escape special
453456
// characters in the ID (versus using the #iconName syntax).
454457
const iconSource = iconSet.querySelector(`[id="${iconName}"]`);
@@ -465,14 +468,14 @@ export class MatIconRegistry implements OnDestroy {
465468
// If the icon node is itself an <svg> node, clone and return it directly. If not, set it as
466469
// the content of a new <svg> node.
467470
if (iconElement.nodeName.toLowerCase() === 'svg') {
468-
return this._setSvgAttributes(iconElement as SVGElement);
471+
return this._setSvgAttributes(iconElement as SVGElement, viewBox);
469472
}
470473

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

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

487-
return this._setSvgAttributes(svg);
490+
return this._setSvgAttributes(svg, viewBox);
488491
}
489492

490493
/**
@@ -520,12 +523,17 @@ export class MatIconRegistry implements OnDestroy {
520523
/**
521524
* Sets the default attributes for an SVG element to be used as an icon.
522525
*/
523-
private _setSvgAttributes(svg: SVGElement): SVGElement {
526+
private _setSvgAttributes(svg: SVGElement, viewBox?: string): SVGElement {
524527
svg.setAttribute('fit', '');
525528
svg.setAttribute('height', '100%');
526529
svg.setAttribute('width', '100%');
527530
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
528531
svg.setAttribute('focusable', 'false'); // Disable IE11 default behavior to make SVGs focusable.
532+
533+
if (viewBox) {
534+
svg.setAttribute('viewBox', viewBox);
535+
}
536+
529537
return svg;
530538
}
531539

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'), '0 0 27 27');
220+
iconRegistry.addSvgIcon('fido', trustUrl('dog.svg'), '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'), '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), '0 0 43 43');
562+
iconRegistry.addSvgIconLiteral('fido', trustHtml(FAKE_SVGS.dog), '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), '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: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ export declare class MatIconModule {
4040

4141
export declare class MatIconRegistry implements OnDestroy {
4242
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;
43+
addSvgIcon(iconName: string, url: SafeResourceUrl, viewBox?: string): this;
44+
addSvgIconInNamespace(namespace: string, iconName: string, url: SafeResourceUrl, viewBox?: string): this;
45+
addSvgIconLiteral(iconName: string, literal: SafeHtml, viewBox?: string): this;
46+
addSvgIconLiteralInNamespace(namespace: string, iconName: string, literal: SafeHtml, viewBox?: string): this;
47+
addSvgIconSet(url: SafeResourceUrl, viewBox?: string): this;
48+
addSvgIconSetInNamespace(namespace: string, url: SafeResourceUrl, viewBox?: string): this;
49+
addSvgIconSetLiteral(literal: SafeHtml, viewBox?: string): this;
50+
addSvgIconSetLiteralInNamespace(namespace: string, literal: SafeHtml, viewBox?: string): this;
5151
classNameForFontAlias(alias: string): string;
5252
getDefaultFontSetClass(): string;
5353
getNamedSvgIcon(name: string, namespace?: string): Observable<SVGElement>;

0 commit comments

Comments
 (0)