From 6b8868dc328c59068ef6b24f7c2db9d7d0ee1962 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Fri, 5 Oct 2018 09:02:23 +0300 Subject: [PATCH] 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. --- src/lib/icon/icon.spec.ts | 32 +++++++++++++- src/lib/icon/icon.ts | 90 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/lib/icon/icon.spec.ts b/src/lib/icon/icon.spec.ts index d8df8da37346..0e265e0ee8ef 100644 --- a/src/lib/icon/icon.spec.ts +++ b/src/lib/icon/icon.spec.ts @@ -2,7 +2,7 @@ import {inject, async, fakeAsync, tick, TestBed} from '@angular/core/testing'; import {SafeResourceUrl, DomSanitizer, SafeHtml} from '@angular/platform-browser'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; import {Component} from '@angular/core'; -import {MatIconModule} from './index'; +import {MatIconModule, MAT_ICON_LOCATION} from './index'; import {MatIconRegistry, getMatIconNoHttpProviderError} from './icon-registry'; import {FAKE_SVGS} from './fake-svgs'; import {wrappedErrorMessage} from '@angular/cdk/testing'; @@ -52,7 +52,11 @@ describe('MatIcon', () => { IconWithBindingAndNgIf, InlineIcon, SvgIconWithUserContent, - ] + ], + providers: [{ + provide: MAT_ICON_LOCATION, + useValue: {pathname: '/fake-path'} + }] }); TestBed.compileComponents(); @@ -580,6 +584,30 @@ describe('MatIcon', () => { tick(); })); + + it('should prepend the current path to attributes with `url()` references', fakeAsync(() => { + iconRegistry.addSvgIconLiteral('fido', trustHtml(` + + + + + + + + `)); + + const fixture = TestBed.createComponent(IconFromSvgName); + fixture.componentInstance.iconName = 'fido'; + fixture.detectChanges(); + const circle = fixture.nativeElement.querySelector('mat-icon svg circle'); + + // We use a regex to match here, rather than the exact value, because different browsers + // return different quotes through `getAttribute`, while some even omit the quotes altogether. + expect(circle.getAttribute('filter')).toMatch(/^url\(['"]?\/fake-path#blur['"]?\)$/); + + tick(); + })); + }); describe('custom fonts', () => { diff --git a/src/lib/icon/icon.ts b/src/lib/icon/icon.ts index 2c5f67d554ef..a091d25191e7 100644 --- a/src/lib/icon/icon.ts +++ b/src/lib/icon/icon.ts @@ -17,7 +17,12 @@ import { OnInit, SimpleChanges, ViewEncapsulation, + Optional, + InjectionToken, + inject, + Inject, } from '@angular/core'; +import {DOCUMENT} from '@angular/common'; import {CanColor, CanColorCtor, mixinColor} from '@angular/material/core'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {MatIconRegistry} from './icon-registry'; @@ -31,6 +36,53 @@ export class MatIconBase { export const _MatIconMixinBase: CanColorCtor & typeof MatIconBase = mixinColor(MatIconBase); +/** + * Injection token used to provide the current location to `MatIcon`. + * Used to handle server-side rendering and to stub out during unit tests. + * @docs-private + */ +export const MAT_ICON_LOCATION = new InjectionToken('mat-icon-location', { + providedIn: 'root', + factory: MAT_ICON_LOCATION_FACTORY +}); + +/** + * Stubbed out location for `MatIcon`. + * @docs-private + */ +export interface MatIconLocation { + pathname: string; +} + +/** @docs-private */ +export function MAT_ICON_LOCATION_FACTORY(): MatIconLocation { + const _document = inject(DOCUMENT); + const pathname = (_document && _document.location && _document.location.pathname) || ''; + return {pathname}; +} + + +/** SVG attributes that accept a FuncIRI (e.g. `url()`). */ +const funcIriAttributes = [ + 'clip-path', + 'color-profile', + 'src', + 'cursor', + 'fill', + 'filter', + 'marker', + 'marker-start', + 'marker-mid', + 'marker-end', + 'mask', + 'stroke' +]; + +/** Selector that can be used to find all elements that are using a `FuncIRI`. */ +const funcIriAttributeSelector = funcIriAttributes.map(attr => `[${attr}]`).join(', '); + +/** Regex that can be used to extract the id out of a FuncIRI. */ +const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/; /** * 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 constructor( elementRef: ElementRef, private _iconRegistry: MatIconRegistry, - @Attribute('aria-hidden') ariaHidden: string) { + @Attribute('aria-hidden') ariaHidden: string, + /** + * @deprecated `location` parameter to be made required. + * @breaking-change 8.0.0 + */ + @Optional() @Inject(MAT_ICON_LOCATION) private _location?: MatIconLocation) { super(elementRef); // 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 styleTags[i].textContent += ' '; } + // Note: we do this fix here, rather than the icon registry, because the + // references have to point to the URL at the time that the icon was created. + this._prependCurrentPathToReferences(svg); this._elementRef.nativeElement.appendChild(svg); } @@ -251,4 +311,32 @@ export class MatIcon extends _MatIconMixinBase implements OnChanges, OnInit, Can private _cleanupFontValue(value: string) { return typeof value === 'string' ? value.trim().split(' ')[0] : value; } + + /** + * Prepends the current path to all elements that have an attribute pointing to a `FuncIRI` + * reference. This is required because WebKit browsers require references to be prefixed with + * the current path, if the page has a `base` tag. + */ + private _prependCurrentPathToReferences(element: SVGElement) { + // @breaking-change 8.0.0 Remove this null check once `_location` parameter is required. + if (!this._location) { + return; + } + + const elementsWithFuncIri = element.querySelectorAll(funcIriAttributeSelector); + const path = this._location.pathname ? this._location.pathname.split('#')[0] : ''; + + for (let i = 0; i < elementsWithFuncIri.length; i++) { + funcIriAttributes.forEach(attr => { + const value = elementsWithFuncIri[i].getAttribute(attr); + const match = value ? value.match(funcIriPattern) : null; + + if (match) { + // Note the quotes inside the `url()`. They're important, because URLs pointing to named + // router outlets can contain parentheses which will break if they aren't quoted. + elementsWithFuncIri[i].setAttribute(attr, `url('${path}#${match[1]}')`); + } + }); + } + } }