Skip to content

Commit 301fd78

Browse files
committed
fix(icon): handle references for pages with base tag
Prepends the current path to any SVG elements with attributes pointing to something by id. If the reference isn't prefixed, it won't work on Safari if the page has a `base` tag (which is used by most Angular apps that are using the router). Fixes #9276.
1 parent 193c2d0 commit 301fd78

File tree

2 files changed

+120
-3
lines changed

2 files changed

+120
-3
lines changed

src/lib/icon/icon.spec.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import {inject, async, fakeAsync, tick, TestBed} from '@angular/core/testing';
22
import {SafeResourceUrl, DomSanitizer, SafeHtml} from '@angular/platform-browser';
33
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
44
import {Component} from '@angular/core';
5-
import {MatIconModule} from './index';
5+
import {Location} from '@angular/common';
6+
import {MatIconModule, MAT_ICON_LOCATION} from './index';
67
import {MatIconRegistry, getMatIconNoHttpProviderError} from './icon-registry';
78
import {FAKE_SVGS} from './fake-svgs';
89
import {wrappedErrorMessage} from '@angular/cdk/testing';
@@ -52,7 +53,11 @@ describe('MatIcon', () => {
5253
IconWithBindingAndNgIf,
5354
InlineIcon,
5455
SvgIconWithUserContent,
55-
]
56+
],
57+
providers: [{
58+
provide: MAT_ICON_LOCATION,
59+
useValue: {pathname: '/fake-path'}
60+
}]
5661
});
5762

5863
TestBed.compileComponents();
@@ -580,6 +585,30 @@ describe('MatIcon', () => {
580585

581586
tick();
582587
}));
588+
589+
it('should prepend the current path to attributes with `url()` references', fakeAsync(() => {
590+
iconRegistry.addSvgIconLiteral('fido', trustHtml(`
591+
<svg>
592+
<filter id="blur">
593+
<feGaussianBlur in="SourceGraphic" stdDeviation="5" />
594+
</filter>
595+
596+
<circle cx="170" cy="60" r="50" fill="green" filter="url('#blur')" />
597+
</svg>
598+
`));
599+
600+
const fixture = TestBed.createComponent(IconFromSvgName);
601+
fixture.componentInstance.iconName = 'fido';
602+
fixture.detectChanges();
603+
const circle = fixture.nativeElement.querySelector('mat-icon svg circle');
604+
605+
// We use a regex to match here, rather than the exact value, because different browsers
606+
// return different quotes through `getAttribute`, while some even omit the quotes altogether.
607+
expect(circle.getAttribute('filter')).toMatch(/^url\(['"]?\/fake-path#blur['"]?\)$/);
608+
609+
tick();
610+
}));
611+
583612
});
584613

585614
describe('custom fonts', () => {

src/lib/icon/icon.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ import {
1717
OnInit,
1818
SimpleChanges,
1919
ViewEncapsulation,
20+
Optional,
21+
InjectionToken,
22+
inject,
23+
Inject,
2024
} from '@angular/core';
25+
import {DOCUMENT} from '@angular/common';
2126
import {CanColor, CanColorCtor, mixinColor} from '@angular/material/core';
2227
import {coerceBooleanProperty} from '@angular/cdk/coercion';
2328
import {MatIconRegistry} from './icon-registry';
@@ -31,6 +36,53 @@ export class MatIconBase {
3136
export const _MatIconMixinBase: CanColorCtor & typeof MatIconBase =
3237
mixinColor(MatIconBase);
3338

39+
/**
40+
* Injection token used to provide the current location to `MatIcon`.
41+
* Used to handle server-side rendering and to stub out during unit tests.
42+
* @docs-private
43+
*/
44+
export const MAT_ICON_LOCATION = new InjectionToken<MatIconLocation>('mat-icon-location', {
45+
providedIn: 'root',
46+
factory: MAT_ICON_LOCATION_FACTORY
47+
});
48+
49+
/**
50+
* Stubbed out location for `MatIcon`.
51+
* @docs-private
52+
*/
53+
export interface MatIconLocation {
54+
pathname: string;
55+
}
56+
57+
/** @docs-private */
58+
export function MAT_ICON_LOCATION_FACTORY(): MatIconLocation {
59+
const _document = inject(DOCUMENT);
60+
const pathname = (_document && _document.location && _document.location.pathname) || '';
61+
return {pathname};
62+
}
63+
64+
65+
/** SVG attributes that accept a FuncIRI (e.g. `url(<something>)`). */
66+
const funcIriAttributes = [
67+
'clip-path',
68+
'color-profile',
69+
'src',
70+
'cursor',
71+
'fill',
72+
'filter',
73+
'marker',
74+
'marker-start',
75+
'marker-mid',
76+
'marker-end',
77+
'mask',
78+
'stroke'
79+
];
80+
81+
/** Selector that can be used to find all elements that are using a `FuncIRI`. */
82+
const funcIriAttributeSelector = funcIriAttributes.map(attr => `[${attr}]`).join(', ');
83+
84+
/** Regex that can be used to extract the id out of a FuncIRI. */
85+
const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
3486

3587
/**
3688
* Component to display an icon. It can be used in the following ways:
@@ -113,7 +165,12 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Can
113165
constructor(
114166
elementRef: ElementRef<HTMLElement>,
115167
private _iconRegistry: MatIconRegistry,
116-
@Attribute('aria-hidden') ariaHidden: string) {
168+
@Attribute('aria-hidden') ariaHidden: string,
169+
/**
170+
* @deprecated `location` parameter to be made required.
171+
* @breaking-change 8.0.0
172+
*/
173+
@Optional() @Inject(MAT_ICON_LOCATION) private _location?: MatIconLocation) {
117174
super(elementRef);
118175

119176
// If the user has not explicitly set aria-hidden, mark the icon as hidden, as this is
@@ -192,6 +249,9 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Can
192249
styleTags[i].textContent += ' ';
193250
}
194251

252+
// Note: we do this fix here, rather than the icon registry, because the
253+
// references have to point to the URL at the time that the icon was created.
254+
this._prependCurrentPathToReferences(svg);
195255
this._elementRef.nativeElement.appendChild(svg);
196256
}
197257

@@ -251,4 +311,32 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Can
251311
private _cleanupFontValue(value: string) {
252312
return typeof value === 'string' ? value.trim().split(' ')[0] : value;
253313
}
314+
315+
/**
316+
* Prepends the current path to all elements that have an attribute pointing to a `FuncIRI`
317+
* reference. This is required because WebKit browsers require references to be prefixed with
318+
* the current path, if the page has a `base` tag.
319+
*/
320+
private _prependCurrentPathToReferences(element: SVGElement) {
321+
// @breaking-change 8.0.0 Remove this null check once `_location` parameter is required.
322+
if (!this._location) {
323+
return;
324+
}
325+
326+
const elementsWithFuncIri = element.querySelectorAll(funcIriAttributeSelector);
327+
const path = this._location.pathname ? this._location.pathname.split('#')[0] : '';
328+
329+
for (let i = 0; i < elementsWithFuncIri.length; i++) {
330+
funcIriAttributes.forEach(attr => {
331+
const value = elementsWithFuncIri[i].getAttribute(attr);
332+
const match = value ? value.match(funcIriPattern) : null;
333+
334+
if (match) {
335+
// Note the quotes inside the `url()`. They're important, because URLs pointing to named
336+
// router outlets can contain parentheses which will break if they aren't quoted.
337+
elementsWithFuncIri[i].setAttribute(attr, `url('${path}#${match[1]}')`);
338+
}
339+
});
340+
}
341+
}
254342
}

0 commit comments

Comments
 (0)