Skip to content

Commit c116533

Browse files
crisbetojosephperrott
authored andcommitted
fix(progress-spinner): non-default diameter indeterminate animation not working inside Shadow DOM (#16177)
1 parent 5a3e206 commit c116533

File tree

4 files changed

+171
-24
lines changed

4 files changed

+171
-24
lines changed

src/material/progress-spinner/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ ng_test_library(
5050
),
5151
deps = [
5252
":progress-spinner",
53+
"//src/cdk/platform",
5354
"@npm//@angular/platform-browser",
5455
],
5556
)

src/material/progress-spinner/progress-spinner.spec.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import {TestBed, async} from '@angular/core/testing';
2-
import {Component} from '@angular/core';
1+
import {TestBed, async, inject} from '@angular/core/testing';
2+
import {Component, ViewEncapsulation} from '@angular/core';
33
import {By} from '@angular/platform-browser';
4+
import {Platform} from '@angular/cdk/platform';
5+
import {_getShadowRoot} from './progress-spinner';
46
import {
57
MatProgressSpinnerModule,
68
MatProgressSpinner,
79
MAT_PROGRESS_SPINNER_DEFAULT_OPTIONS,
810
} from './index';
911

10-
1112
describe('MatProgressSpinner', () => {
13+
const supportsShadowDom = typeof document.createElement('div').attachShadow !== 'undefined';
1214

1315
beforeEach(async(() => {
1416
TestBed.configureTestingModule({
@@ -22,6 +24,7 @@ describe('MatProgressSpinner', () => {
2224
ProgressSpinnerCustomDiameter,
2325
SpinnerWithColor,
2426
ProgressSpinnerWithStringValues,
27+
IndeterminateSpinnerInShadowDom,
2528
],
2629
}).compileComponents();
2730
}));
@@ -154,6 +157,35 @@ describe('MatProgressSpinner', () => {
154157
.toBe('0 0 25.2 25.2', 'Expected the custom diameter to be applied to the svg viewBox.');
155158
});
156159

160+
it('should add a style tag with the indeterminate animation to the document head when using a ' +
161+
'non-default diameter', inject([Platform], (platform: Platform) => {
162+
// On Edge and IE we use a fallback animation because the
163+
// browser doesn't support animating SVG correctly.
164+
if (platform.EDGE || platform.TRIDENT) {
165+
return;
166+
}
167+
168+
const fixture = TestBed.createComponent(ProgressSpinnerCustomDiameter);
169+
fixture.componentInstance.diameter = 32;
170+
fixture.detectChanges();
171+
172+
expect(document.head.querySelectorAll('style[mat-spinner-animation="32"]').length).toBe(1);
173+
174+
// Change to something different so we get another tag.
175+
fixture.componentInstance.diameter = 64;
176+
fixture.detectChanges();
177+
178+
expect(document.head.querySelectorAll('style[mat-spinner-animation="32"]').length).toBe(1);
179+
expect(document.head.querySelectorAll('style[mat-spinner-animation="64"]').length).toBe(1);
180+
181+
// Change back to the initial one.
182+
fixture.componentInstance.diameter = 32;
183+
fixture.detectChanges();
184+
185+
expect(document.head.querySelectorAll('style[mat-spinner-animation="32"]').length).toBe(1);
186+
expect(document.head.querySelectorAll('style[mat-spinner-animation="64"]').length).toBe(1);
187+
}));
188+
157189
it('should allow a custom stroke width', () => {
158190
const fixture = TestBed.createComponent(ProgressSpinnerCustomStrokeWidth);
159191

@@ -321,6 +353,57 @@ describe('MatProgressSpinner', () => {
321353
expect(progressElement.nativeElement.hasAttribute('aria-valuenow')).toBe(false);
322354
});
323355

356+
it('should add the indeterminate animation style tag to the Shadow root', () => {
357+
// The test is only relevant in browsers that support Shadow DOM.
358+
if (!supportsShadowDom) {
359+
return;
360+
}
361+
362+
const fixture = TestBed.createComponent(IndeterminateSpinnerInShadowDom);
363+
fixture.componentInstance.diameter = 27;
364+
fixture.detectChanges();
365+
366+
const spinner = fixture.debugElement.query(By.css('mat-progress-spinner')).nativeElement;
367+
const shadowRoot = _getShadowRoot(spinner, document) as HTMLElement;
368+
369+
expect(shadowRoot.querySelector('style[mat-spinner-animation="27"]')).toBeTruthy();
370+
371+
fixture.componentInstance.diameter = 15;
372+
fixture.detectChanges();
373+
374+
expect(shadowRoot.querySelector('style[mat-spinner-animation="27"]')).toBeTruthy();
375+
});
376+
377+
it('should not duplicate style tags inside the Shadow root', () => {
378+
// The test is only relevant in browsers that support Shadow DOM.
379+
if (!supportsShadowDom) {
380+
return;
381+
}
382+
383+
const fixture = TestBed.createComponent(IndeterminateSpinnerInShadowDom);
384+
fixture.componentInstance.diameter = 39;
385+
fixture.detectChanges();
386+
387+
const spinner = fixture.debugElement.query(By.css('mat-progress-spinner')).nativeElement;
388+
const shadowRoot = _getShadowRoot(spinner, document) as HTMLElement;
389+
390+
expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="39"]').length).toBe(1);
391+
392+
// Change to something different so we get another tag.
393+
fixture.componentInstance.diameter = 61;
394+
fixture.detectChanges();
395+
396+
expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="39"]').length).toBe(1);
397+
expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="61"]').length).toBe(1);
398+
399+
// Change back to the initial one.
400+
fixture.componentInstance.diameter = 39;
401+
fixture.detectChanges();
402+
403+
expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="39"]').length).toBe(1);
404+
expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="61"]').length).toBe(1);
405+
});
406+
324407
});
325408

326409

@@ -360,3 +443,14 @@ class ProgressSpinnerWithColor { color: string = 'primary'; }
360443
`
361444
})
362445
class ProgressSpinnerWithStringValues { }
446+
447+
448+
@Component({
449+
template: `
450+
<mat-progress-spinner mode="indeterminate" [diameter]="diameter"></mat-progress-spinner>
451+
`,
452+
encapsulation: ViewEncapsulation.ShadowDom,
453+
})
454+
class IndeterminateSpinnerInShadowDom {
455+
diameter: number;
456+
}

src/material/progress-spinner/progress-spinner.ts

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -124,19 +124,24 @@ const INDETERMINATE_ANIMATION_TEMPLATE = `
124124
encapsulation: ViewEncapsulation.None,
125125
})
126126
export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements CanColor {
127-
128127
private _value = 0;
129128
private _strokeWidth: number;
130129
private _fallbackAnimation = false;
131130

132-
/** Tracks diameters of existing instances to de-dupe generated styles (default d = 100) */
133-
private static _diameters = new Set<number>([BASE_SIZE]);
131+
/**
132+
* Element to which we should add the generated style tags for the indeterminate animation.
133+
* For most elements this is the document, but for the ones in the Shadow DOM we need to
134+
* use the shadow root.
135+
*/
136+
private _styleRoot: Node;
134137

135138
/**
136-
* Used for storing all of the generated keyframe animations.
137-
* @dynamic
139+
* Tracks diameters of existing instances to de-dupe generated styles (default d = 100).
140+
* We need to keep track of which elements the diameters were attached to, because for
141+
* elements in the Shadow DOM the style tags are attached to the shadow root, rather
142+
* than the document head.
138143
*/
139-
private static _styleTag: HTMLStyleElement|null = null;
144+
private static _diameters = new WeakMap<Node, Set<number>>();
140145

141146
/** Whether the _mat-animation-noopable class should be applied, disabling animations. */
142147
_noopAnimations: boolean;
@@ -147,8 +152,13 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements
147152
set diameter(size: number) {
148153
this._diameter = coerceNumberProperty(size);
149154

150-
if (!this._fallbackAnimation && !MatProgressSpinner._diameters.has(this._diameter)) {
151-
this._attachStyleNode();
155+
if (!this._fallbackAnimation) {
156+
const trackedDiameters = MatProgressSpinner._diameters;
157+
const diametersForElement = trackedDiameters.get(this._styleRoot);
158+
159+
if (!diametersForElement || !diametersForElement.has(this._diameter)) {
160+
this._attachStyleNode();
161+
}
152162
}
153163
}
154164
private _diameter = BASE_SIZE;
@@ -182,6 +192,16 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements
182192
defaults?: MatProgressSpinnerDefaultOptions) {
183193

184194
super(_elementRef);
195+
196+
const trackedDiameters = MatProgressSpinner._diameters;
197+
198+
// The base size is already inserted via the component's structural styles. We still
199+
// need to track it so we don't end up adding the same styles again.
200+
if (!trackedDiameters.has(_document.head)) {
201+
trackedDiameters.set(_document.head, new Set<number>([BASE_SIZE]));
202+
}
203+
204+
this._styleRoot = _getShadowRoot(_elementRef.nativeElement, _document) || _document.head;
185205
this._fallbackAnimation = platform.EDGE || platform.TRIDENT;
186206
this._noopAnimations = animationMode === 'NoopAnimations' &&
187207
(!!defaults && !defaults._forceAnimations);
@@ -241,19 +261,22 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements
241261

242262
/** Dynamically generates a style tag containing the correct animation for this diameter. */
243263
private _attachStyleNode(): void {
244-
let styleTag = MatProgressSpinner._styleTag;
245-
246-
if (!styleTag) {
247-
styleTag = this._document.createElement('style');
248-
this._document.head.appendChild(styleTag);
249-
MatProgressSpinner._styleTag = styleTag;
264+
const styleTag: HTMLStyleElement = this._document.createElement('style');
265+
const styleRoot = this._styleRoot;
266+
const currentDiameter = this._diameter;
267+
const diameters = MatProgressSpinner._diameters;
268+
let diametersForElement = diameters.get(styleRoot);
269+
270+
styleTag.setAttribute('mat-spinner-animation', currentDiameter + '');
271+
styleTag.textContent = this._getAnimationText();
272+
styleRoot.appendChild(styleTag);
273+
274+
if (!diametersForElement) {
275+
diametersForElement = new Set<number>();
276+
diameters.set(styleRoot, diametersForElement);
250277
}
251278

252-
if (styleTag && styleTag.sheet) {
253-
(styleTag.sheet as CSSStyleSheet).insertRule(this._getAnimationText(), 0);
254-
}
255-
256-
MatProgressSpinner._diameters.add(this.diameter);
279+
diametersForElement.add(currentDiameter);
257280
}
258281

259282
/** Generates animation styles adjusted for the spinner's diameter. */
@@ -300,3 +323,26 @@ export class MatSpinner extends MatProgressSpinner {
300323
this.mode = 'indeterminate';
301324
}
302325
}
326+
327+
328+
/** Gets the shadow root of an element, if supported and the element is inside the Shadow DOM. */
329+
export function _getShadowRoot(element: HTMLElement, _document: Document): Node | null {
330+
// TODO(crisbeto): see whether we should move this into the CDK
331+
// feature detection utilities once #15616 gets merged in.
332+
if (typeof window !== 'undefined') {
333+
const head = _document.head;
334+
335+
// Check whether the browser supports Shadow DOM.
336+
if (head && ((head as any).createShadowRoot || head.attachShadow)) {
337+
const rootNode = element.getRootNode ? element.getRootNode() : null;
338+
339+
// We need to take the `ShadowRoot` off of `window`, because the built-in types are
340+
// incorrect. See https://github.com/Microsoft/TypeScript/issues/27929.
341+
if (rootNode instanceof (window as any).ShadowRoot) {
342+
return rootNode;
343+
}
344+
}
345+
}
346+
347+
return null;
348+
}

src/material/progress-spinner/public-api.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,11 @@
77
*/
88

99
export * from './progress-spinner-module';
10-
export * from './progress-spinner';
11-
10+
export {
11+
MatProgressSpinner,
12+
MatSpinner,
13+
MAT_PROGRESS_SPINNER_DEFAULT_OPTIONS,
14+
ProgressSpinnerMode,
15+
MatProgressSpinnerDefaultOptions,
16+
MAT_PROGRESS_SPINNER_DEFAULT_OPTIONS_FACTORY,
17+
} from './progress-spinner';

0 commit comments

Comments
 (0)