Skip to content

Commit 9e5fd91

Browse files
crisbetovivian-hu-zz
authored andcommitted
fix(icon): handle references for pages with base tag (#12428)
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 3fc0d36 commit 9e5fd91

File tree

2 files changed

+119
-3
lines changed

2 files changed

+119
-3
lines changed

src/lib/icon/icon.spec.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ 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 {MatIconModule, MAT_ICON_LOCATION} from './index';
66
import {MatIconRegistry, getMatIconNoHttpProviderError} from './icon-registry';
77
import {FAKE_SVGS} from './fake-svgs';
88
import {wrappedErrorMessage} from '@angular/cdk/testing';
@@ -52,7 +52,11 @@ describe('MatIcon', () => {
5252
IconWithBindingAndNgIf,
5353
InlineIcon,
5454
SvgIconWithUserContent,
55-
]
55+
],
56+
providers: [{
57+
provide: MAT_ICON_LOCATION,
58+
useValue: {pathname: '/fake-path'}
59+
}]
5660
});
5761

5862
TestBed.compileComponents();
@@ -580,6 +584,30 @@ describe('MatIcon', () => {
580584

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

585613
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)