From c11653334b19cc524c39cd6dfbb2a5b1d06e28ae Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 4 Jun 2019 18:41:21 +0200 Subject: [PATCH] fix(progress-spinner): non-default diameter indeterminate animation not working inside Shadow DOM (#16177) --- src/material/progress-spinner/BUILD.bazel | 1 + .../progress-spinner/progress-spinner.spec.ts | 100 +++++++++++++++++- .../progress-spinner/progress-spinner.ts | 82 ++++++++++---- src/material/progress-spinner/public-api.ts | 10 +- 4 files changed, 170 insertions(+), 23 deletions(-) diff --git a/src/material/progress-spinner/BUILD.bazel b/src/material/progress-spinner/BUILD.bazel index afb70525de7..a93da25d5ef 100644 --- a/src/material/progress-spinner/BUILD.bazel +++ b/src/material/progress-spinner/BUILD.bazel @@ -50,6 +50,7 @@ ng_test_library( ), deps = [ ":progress-spinner", + "//src/cdk/platform", "@npm//@angular/platform-browser", ], ) diff --git a/src/material/progress-spinner/progress-spinner.spec.ts b/src/material/progress-spinner/progress-spinner.spec.ts index dc0dcaa212c..89288947993 100644 --- a/src/material/progress-spinner/progress-spinner.spec.ts +++ b/src/material/progress-spinner/progress-spinner.spec.ts @@ -1,14 +1,16 @@ -import {TestBed, async} from '@angular/core/testing'; -import {Component} from '@angular/core'; +import {TestBed, async, inject} from '@angular/core/testing'; +import {Component, ViewEncapsulation} from '@angular/core'; import {By} from '@angular/platform-browser'; +import {Platform} from '@angular/cdk/platform'; +import {_getShadowRoot} from './progress-spinner'; import { MatProgressSpinnerModule, MatProgressSpinner, MAT_PROGRESS_SPINNER_DEFAULT_OPTIONS, } from './index'; - describe('MatProgressSpinner', () => { + const supportsShadowDom = typeof document.createElement('div').attachShadow !== 'undefined'; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -22,6 +24,7 @@ describe('MatProgressSpinner', () => { ProgressSpinnerCustomDiameter, SpinnerWithColor, ProgressSpinnerWithStringValues, + IndeterminateSpinnerInShadowDom, ], }).compileComponents(); })); @@ -154,6 +157,35 @@ describe('MatProgressSpinner', () => { .toBe('0 0 25.2 25.2', 'Expected the custom diameter to be applied to the svg viewBox.'); }); + it('should add a style tag with the indeterminate animation to the document head when using a ' + + 'non-default diameter', inject([Platform], (platform: Platform) => { + // On Edge and IE we use a fallback animation because the + // browser doesn't support animating SVG correctly. + if (platform.EDGE || platform.TRIDENT) { + return; + } + + const fixture = TestBed.createComponent(ProgressSpinnerCustomDiameter); + fixture.componentInstance.diameter = 32; + fixture.detectChanges(); + + expect(document.head.querySelectorAll('style[mat-spinner-animation="32"]').length).toBe(1); + + // Change to something different so we get another tag. + fixture.componentInstance.diameter = 64; + fixture.detectChanges(); + + expect(document.head.querySelectorAll('style[mat-spinner-animation="32"]').length).toBe(1); + expect(document.head.querySelectorAll('style[mat-spinner-animation="64"]').length).toBe(1); + + // Change back to the initial one. + fixture.componentInstance.diameter = 32; + fixture.detectChanges(); + + expect(document.head.querySelectorAll('style[mat-spinner-animation="32"]').length).toBe(1); + expect(document.head.querySelectorAll('style[mat-spinner-animation="64"]').length).toBe(1); + })); + it('should allow a custom stroke width', () => { const fixture = TestBed.createComponent(ProgressSpinnerCustomStrokeWidth); @@ -321,6 +353,57 @@ describe('MatProgressSpinner', () => { expect(progressElement.nativeElement.hasAttribute('aria-valuenow')).toBe(false); }); + it('should add the indeterminate animation style tag to the Shadow root', () => { + // The test is only relevant in browsers that support Shadow DOM. + if (!supportsShadowDom) { + return; + } + + const fixture = TestBed.createComponent(IndeterminateSpinnerInShadowDom); + fixture.componentInstance.diameter = 27; + fixture.detectChanges(); + + const spinner = fixture.debugElement.query(By.css('mat-progress-spinner')).nativeElement; + const shadowRoot = _getShadowRoot(spinner, document) as HTMLElement; + + expect(shadowRoot.querySelector('style[mat-spinner-animation="27"]')).toBeTruthy(); + + fixture.componentInstance.diameter = 15; + fixture.detectChanges(); + + expect(shadowRoot.querySelector('style[mat-spinner-animation="27"]')).toBeTruthy(); + }); + + it('should not duplicate style tags inside the Shadow root', () => { + // The test is only relevant in browsers that support Shadow DOM. + if (!supportsShadowDom) { + return; + } + + const fixture = TestBed.createComponent(IndeterminateSpinnerInShadowDom); + fixture.componentInstance.diameter = 39; + fixture.detectChanges(); + + const spinner = fixture.debugElement.query(By.css('mat-progress-spinner')).nativeElement; + const shadowRoot = _getShadowRoot(spinner, document) as HTMLElement; + + expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="39"]').length).toBe(1); + + // Change to something different so we get another tag. + fixture.componentInstance.diameter = 61; + fixture.detectChanges(); + + expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="39"]').length).toBe(1); + expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="61"]').length).toBe(1); + + // Change back to the initial one. + fixture.componentInstance.diameter = 39; + fixture.detectChanges(); + + expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="39"]').length).toBe(1); + expect(shadowRoot.querySelectorAll('style[mat-spinner-animation="61"]').length).toBe(1); + }); + }); @@ -360,3 +443,14 @@ class ProgressSpinnerWithColor { color: string = 'primary'; } ` }) class ProgressSpinnerWithStringValues { } + + +@Component({ + template: ` + + `, + encapsulation: ViewEncapsulation.ShadowDom, +}) +class IndeterminateSpinnerInShadowDom { + diameter: number; +} diff --git a/src/material/progress-spinner/progress-spinner.ts b/src/material/progress-spinner/progress-spinner.ts index b1b3375f634..2136d1615de 100644 --- a/src/material/progress-spinner/progress-spinner.ts +++ b/src/material/progress-spinner/progress-spinner.ts @@ -124,19 +124,24 @@ const INDETERMINATE_ANIMATION_TEMPLATE = ` encapsulation: ViewEncapsulation.None, }) export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements CanColor { - private _value = 0; private _strokeWidth: number; private _fallbackAnimation = false; - /** Tracks diameters of existing instances to de-dupe generated styles (default d = 100) */ - private static _diameters = new Set([BASE_SIZE]); + /** + * Element to which we should add the generated style tags for the indeterminate animation. + * For most elements this is the document, but for the ones in the Shadow DOM we need to + * use the shadow root. + */ + private _styleRoot: Node; /** - * Used for storing all of the generated keyframe animations. - * @dynamic + * Tracks diameters of existing instances to de-dupe generated styles (default d = 100). + * We need to keep track of which elements the diameters were attached to, because for + * elements in the Shadow DOM the style tags are attached to the shadow root, rather + * than the document head. */ - private static _styleTag: HTMLStyleElement|null = null; + private static _diameters = new WeakMap>(); /** Whether the _mat-animation-noopable class should be applied, disabling animations. */ _noopAnimations: boolean; @@ -147,8 +152,13 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements set diameter(size: number) { this._diameter = coerceNumberProperty(size); - if (!this._fallbackAnimation && !MatProgressSpinner._diameters.has(this._diameter)) { - this._attachStyleNode(); + if (!this._fallbackAnimation) { + const trackedDiameters = MatProgressSpinner._diameters; + const diametersForElement = trackedDiameters.get(this._styleRoot); + + if (!diametersForElement || !diametersForElement.has(this._diameter)) { + this._attachStyleNode(); + } } } private _diameter = BASE_SIZE; @@ -182,6 +192,16 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements defaults?: MatProgressSpinnerDefaultOptions) { super(_elementRef); + + const trackedDiameters = MatProgressSpinner._diameters; + + // The base size is already inserted via the component's structural styles. We still + // need to track it so we don't end up adding the same styles again. + if (!trackedDiameters.has(_document.head)) { + trackedDiameters.set(_document.head, new Set([BASE_SIZE])); + } + + this._styleRoot = _getShadowRoot(_elementRef.nativeElement, _document) || _document.head; this._fallbackAnimation = platform.EDGE || platform.TRIDENT; this._noopAnimations = animationMode === 'NoopAnimations' && (!!defaults && !defaults._forceAnimations); @@ -241,19 +261,22 @@ export class MatProgressSpinner extends _MatProgressSpinnerMixinBase implements /** Dynamically generates a style tag containing the correct animation for this diameter. */ private _attachStyleNode(): void { - let styleTag = MatProgressSpinner._styleTag; + const styleTag: HTMLStyleElement = this._document.createElement('style'); + const styleRoot = this._styleRoot; + const currentDiameter = this._diameter; + const diameters = MatProgressSpinner._diameters; + let diametersForElement = diameters.get(styleRoot); - if (!styleTag) { - styleTag = this._document.createElement('style'); - this._document.head.appendChild(styleTag); - MatProgressSpinner._styleTag = styleTag; + styleTag.setAttribute('mat-spinner-animation', currentDiameter + ''); + styleTag.textContent = this._getAnimationText(); + styleRoot.appendChild(styleTag); + + if (!diametersForElement) { + diametersForElement = new Set(); + diameters.set(styleRoot, diametersForElement); } - if (styleTag && styleTag.sheet) { - (styleTag.sheet as CSSStyleSheet).insertRule(this._getAnimationText(), 0); - } - - MatProgressSpinner._diameters.add(this.diameter); + diametersForElement.add(currentDiameter); } /** Generates animation styles adjusted for the spinner's diameter. */ @@ -300,3 +323,26 @@ export class MatSpinner extends MatProgressSpinner { this.mode = 'indeterminate'; } } + + +/** Gets the shadow root of an element, if supported and the element is inside the Shadow DOM. */ +export function _getShadowRoot(element: HTMLElement, _document: Document): Node | null { + // TODO(crisbeto): see whether we should move this into the CDK + // feature detection utilities once #15616 gets merged in. + if (typeof window !== 'undefined') { + const head = _document.head; + + // Check whether the browser supports Shadow DOM. + if (head && ((head as any).createShadowRoot || head.attachShadow)) { + const rootNode = element.getRootNode ? element.getRootNode() : null; + + // We need to take the `ShadowRoot` off of `window`, because the built-in types are + // incorrect. See https://github.com/Microsoft/TypeScript/issues/27929. + if (rootNode instanceof (window as any).ShadowRoot) { + return rootNode; + } + } + } + + return null; +} diff --git a/src/material/progress-spinner/public-api.ts b/src/material/progress-spinner/public-api.ts index 42fb976850e..b67d2c313a1 100644 --- a/src/material/progress-spinner/public-api.ts +++ b/src/material/progress-spinner/public-api.ts @@ -7,5 +7,11 @@ */ export * from './progress-spinner-module'; -export * from './progress-spinner'; - +export { + MatProgressSpinner, + MatSpinner, + MAT_PROGRESS_SPINNER_DEFAULT_OPTIONS, + ProgressSpinnerMode, + MatProgressSpinnerDefaultOptions, + MAT_PROGRESS_SPINNER_DEFAULT_OPTIONS_FACTORY, +} from './progress-spinner';