From 6a1a70753e21eca1934c1afd51c1eb09f49dd97c Mon Sep 17 00:00:00 2001 From: Louis Chandra Date: Tue, 28 Aug 2018 07:59:40 -0700 Subject: [PATCH] feat(progress-bar): add transitionend output for value animation (#12409) --- .../progress-bar/progress-bar-demo.html | 10 +- .../progress-bar/progress-bar-demo.ts | 2 + src/lib/progress-bar/progress-bar.html | 2 +- src/lib/progress-bar/progress-bar.spec.ts | 279 ++++++++++++------ src/lib/progress-bar/progress-bar.ts | 75 ++++- 5 files changed, 267 insertions(+), 101 deletions(-) diff --git a/src/demo-app/progress-bar/progress-bar-demo.html b/src/demo-app/progress-bar/progress-bar-demo.html index 62acae8389e..0aec6d4166f 100644 --- a/src/demo-app/progress-bar/progress-bar-demo.html +++ b/src/demo-app/progress-bar/progress-bar-demo.html @@ -8,12 +8,15 @@
Value: {{determinateProgressValue}} +
+ Last animation complete value: {{determinateAnimationEndValue}}
- +
@@ -21,6 +24,8 @@
Value: {{bufferProgressValue}} +
+ Last animation complete value: {{bufferAnimationEndValue}} @@ -31,7 +36,8 @@
+ [color]="color" (animationEnd)="bufferAnimationEndValue = $event.value"> +

Indeterminate

diff --git a/src/demo-app/progress-bar/progress-bar-demo.ts b/src/demo-app/progress-bar/progress-bar-demo.ts index 15aa2c0ef8e..1d9db4663aa 100644 --- a/src/demo-app/progress-bar/progress-bar-demo.ts +++ b/src/demo-app/progress-bar/progress-bar-demo.ts @@ -20,6 +20,8 @@ import {Component} from '@angular/core'; export class ProgressBarDemo { color: string = 'primary'; determinateProgressValue: number = 30; + determinateAnimationEndValue: number; + bufferAnimationEndValue: number; bufferProgressValue: number = 30; bufferBufferValue: number = 40; diff --git a/src/lib/progress-bar/progress-bar.html b/src/lib/progress-bar/progress-bar.html index bf2bf2ad9bb..8d394133c4b 100644 --- a/src/lib/progress-bar/progress-bar.html +++ b/src/lib/progress-bar/progress-bar.html @@ -11,5 +11,5 @@
-
+
diff --git a/src/lib/progress-bar/progress-bar.spec.ts b/src/lib/progress-bar/progress-bar.spec.ts index 9b75b4f6590..c277dbc4eed 100644 --- a/src/lib/progress-bar/progress-bar.spec.ts +++ b/src/lib/progress-bar/progress-bar.spec.ts @@ -1,15 +1,19 @@ -import {TestBed, ComponentFixture} from '@angular/core/testing'; -import {Component, Type} from '@angular/core'; +import {TestBed, async, ComponentFixture} from '@angular/core/testing'; +import {Component, DebugElement, Type} from '@angular/core'; import {By} from '@angular/platform-browser'; +import {dispatchFakeEvent} from '@angular/cdk/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {MatProgressBarModule, MAT_PROGRESS_BAR_LOCATION} from './index'; +import {MatProgressBar} from './progress-bar'; describe('MatProgressBar', () => { let fakePath = '/fake-path'; - function createComponent(componentType: Type): ComponentFixture { + function createComponent(componentType: Type, + imports?: Array>): ComponentFixture { TestBed.configureTestingModule({ - imports: [MatProgressBarModule], + imports: imports || [MatProgressBarModule], declarations: [componentType], providers: [{ provide: MAT_PROGRESS_BAR_LOCATION, @@ -20,119 +24,210 @@ describe('MatProgressBar', () => { return TestBed.createComponent(componentType); } - describe('basic progress-bar', () => { - it('should apply a mode of "determinate" if no mode is provided.', () => { - const fixture = createComponent(BasicProgressBar); - fixture.detectChanges(); + describe('with animation', () => { + describe('basic progress-bar', () => { + it('should apply a mode of "determinate" if no mode is provided.', () => { + const fixture = createComponent(BasicProgressBar); + fixture.detectChanges(); + const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); + expect(progressElement.componentInstance.mode).toBe('determinate'); + }); - const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); - expect(progressElement.componentInstance.mode).toBe('determinate'); + it('should define default values for value and bufferValue attributes', () => { + const fixture = createComponent(BasicProgressBar); + fixture.detectChanges(); + const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); + expect(progressElement.componentInstance.value).toBe(0); + expect(progressElement.componentInstance.bufferValue).toBe(0); + }); + + it('should clamp value and bufferValue between 0 and 100', () => { + const fixture = createComponent(BasicProgressBar); + fixture.detectChanges(); + + const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); + const progressComponent = progressElement.componentInstance; + + progressComponent.value = 50; + expect(progressComponent.value).toBe(50); + + progressComponent.value = 999; + expect(progressComponent.value).toBe(100); + + progressComponent.value = -10; + expect(progressComponent.value).toBe(0); + + progressComponent.bufferValue = -29; + expect(progressComponent.bufferValue).toBe(0); + + progressComponent.bufferValue = 9; + expect(progressComponent.bufferValue).toBe(9); + + progressComponent.bufferValue = 1320; + expect(progressComponent.bufferValue).toBe(100); + }); + + it('should return the transform attribute for bufferValue and mode', () => { + const fixture = createComponent(BasicProgressBar); + fixture.detectChanges(); + + const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); + const progressComponent = progressElement.componentInstance; + + expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0)'}); + expect(progressComponent._bufferTransform()).toBe(undefined); + + progressComponent.value = 40; + expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0.4)'}); + expect(progressComponent._bufferTransform()).toBe(undefined); + + progressComponent.value = 35; + progressComponent.bufferValue = 55; + expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0.35)'}); + expect(progressComponent._bufferTransform()).toBe(undefined); + + progressComponent.mode = 'buffer'; + expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0.35)'}); + expect(progressComponent._bufferTransform()).toEqual({transform: 'scaleX(0.55)'}); + + + progressComponent.value = 60; + progressComponent.bufferValue = 60; + expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0.6)'}); + expect(progressComponent._bufferTransform()).toEqual({transform: 'scaleX(0.6)'}); + }); + + it('should prefix SVG references with the current path', () => { + const fixture = createComponent(BasicProgressBar); + fixture.detectChanges(); + + const rect = fixture.debugElement.query(By.css('rect')).nativeElement; + expect(rect.getAttribute('fill')).toMatch(/^url\(['"]?\/fake-path#.*['"]?\)$/); + }); + + it('should account for location hash when prefixing the SVG references', () => { + fakePath = '/fake-path#anchor'; + + const fixture = createComponent(BasicProgressBar); + fixture.detectChanges(); + + const rect = fixture.debugElement.query(By.css('rect')).nativeElement; + expect(rect.getAttribute('fill')).not.toContain('#anchor#'); + }); + + it('should not be able to tab into the underlying SVG element', () => { + const fixture = createComponent(BasicProgressBar); + fixture.detectChanges(); + + const svg = fixture.debugElement.query(By.css('svg')).nativeElement; + expect(svg.getAttribute('focusable')).toBe('false'); + }); }); - it('should define default values for value and bufferValue attributes', () => { - const fixture = createComponent(BasicProgressBar); - fixture.detectChanges(); + describe('animation trigger on determinate setting', () => { + let fixture: ComponentFixture; + let progressComponent: MatProgressBar; + let primaryValueBar: DebugElement; - const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); - expect(progressElement.componentInstance.value).toBe(0); - expect(progressElement.componentInstance.bufferValue).toBe(0); + beforeEach(() => { + fixture = createComponent(BasicProgressBar); + + const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); + progressComponent = progressElement.componentInstance; + primaryValueBar = progressElement.query(By.css('.mat-progress-bar-primary')); + }); + + it('should trigger output event on primary value bar animation end', () => { + fixture.detectChanges(); + spyOn(progressComponent.animationEnd, 'next'); + + progressComponent.value = 40; + expect(progressComponent.animationEnd.next).not.toHaveBeenCalled(); + + // On animation end, output should be emitted. + dispatchFakeEvent(primaryValueBar.nativeElement, 'transitionend'); + expect(progressComponent.animationEnd.next).toHaveBeenCalledWith({ value: 40 }); + }); }); - it('should clamp value and bufferValue between 0 and 100', () => { - const fixture = createComponent(BasicProgressBar); - fixture.detectChanges(); + describe('animation trigger on buffer setting', () => { + let fixture: ComponentFixture; + let progressComponent: MatProgressBar; + let primaryValueBar: DebugElement; - const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); - const progressComponent = progressElement.componentInstance; + beforeEach(() => { + fixture = createComponent(BufferProgressBar); - progressComponent.value = 50; - expect(progressComponent.value).toBe(50); + const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); + progressComponent = progressElement.componentInstance; + primaryValueBar = progressElement.query(By.css('.mat-progress-bar-primary')); + }); - progressComponent.value = 999; - expect(progressComponent.value).toBe(100); + it('should bind on transitionend eventListener on primaryBarValue', () => { + spyOn(primaryValueBar.nativeElement, 'addEventListener'); + fixture.detectChanges(); - progressComponent.value = -10; - expect(progressComponent.value).toBe(0); + expect(primaryValueBar.nativeElement.addEventListener).toHaveBeenCalled(); + expect(primaryValueBar.nativeElement.addEventListener + .calls.mostRecent().args[0]).toBe('transitionend'); + }); - progressComponent.bufferValue = -29; - expect(progressComponent.bufferValue).toBe(0); + it('should trigger output event on primary value bar animation end', () => { + fixture.detectChanges(); + spyOn(progressComponent.animationEnd, 'next'); - progressComponent.bufferValue = 9; - expect(progressComponent.bufferValue).toBe(9); + progressComponent.value = 40; + expect(progressComponent.animationEnd.next).not.toHaveBeenCalled(); - progressComponent.bufferValue = 1320; - expect(progressComponent.bufferValue).toBe(100); - }); + // On animation end, output should be emitted. + dispatchFakeEvent(primaryValueBar.nativeElement, 'transitionend'); + expect(progressComponent.animationEnd.next).toHaveBeenCalledWith({ value: 40 }); + }); - it('should return the transform attribute for bufferValue and mode', () => { - const fixture = createComponent(BasicProgressBar); - fixture.detectChanges(); + it('should trigger output event with value not bufferValue', () => { + fixture.detectChanges(); + spyOn(progressComponent.animationEnd, 'next'); - const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); - const progressComponent = progressElement.componentInstance; + progressComponent.value = 40; + progressComponent.bufferValue = 70; + expect(progressComponent.animationEnd.next).not.toHaveBeenCalled(); - expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0)'}); - expect(progressComponent._bufferTransform()).toBe(undefined); - - progressComponent.value = 40; - expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0.4)'}); - expect(progressComponent._bufferTransform()).toBe(undefined); - - progressComponent.value = 35; - progressComponent.bufferValue = 55; - expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0.35)'}); - expect(progressComponent._bufferTransform()).toBe(undefined); - - progressComponent.mode = 'buffer'; - expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0.35)'}); - expect(progressComponent._bufferTransform()).toEqual({transform: 'scaleX(0.55)'}); - - - progressComponent.value = 60; - progressComponent.bufferValue = 60; - expect(progressComponent._primaryTransform()).toEqual({transform: 'scaleX(0.6)'}); - expect(progressComponent._bufferTransform()).toEqual({transform: 'scaleX(0.6)'}); - }); - - it('should prefix SVG references with the current path', () => { - const fixture = createComponent(BasicProgressBar); - fixture.detectChanges(); - - const rect = fixture.debugElement.query(By.css('rect')).nativeElement; - expect(rect.getAttribute('fill')).toMatch(/^url\(['"]?\/fake-path#.*['"]?\)$/); - }); - - it('should account for location hash when prefixing the SVG references', () => { - fakePath = '/fake-path#anchor'; - - const fixture = createComponent(BasicProgressBar); - fixture.detectChanges(); - - const rect = fixture.debugElement.query(By.css('rect')).nativeElement; - expect(rect.getAttribute('fill')).not.toContain('#anchor#'); - }); - - it('should not be able to tab into the underlying SVG element', () => { - const fixture = createComponent(BasicProgressBar); - fixture.detectChanges(); - - const svg = fixture.debugElement.query(By.css('svg')).nativeElement; - expect(svg.getAttribute('focusable')).toBe('false'); + // On animation end, output should be emitted. + dispatchFakeEvent(primaryValueBar.nativeElement, 'transitionend'); + expect(progressComponent.animationEnd.next).toHaveBeenCalledWith({ value: 40 }); + }); }); }); - describe('buffer progress-bar', () => { - it('should not modify the mode if a valid mode is provided.', () => { - const fixture = createComponent(BufferProgressBar); + describe('With NoopAnimations', () => { + let progressComponent: MatProgressBar; + let primaryValueBar: DebugElement; + let fixture: ComponentFixture; + + beforeEach(async(() => { + fixture = createComponent(BasicProgressBar, [MatProgressBarModule, NoopAnimationsModule]); + const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); + progressComponent = progressElement.componentInstance; + primaryValueBar = progressElement.query(By.css('.mat-progress-bar-primary')); + })); + + it('should not bind transition end listener', () => { + spyOn(primaryValueBar.nativeElement, 'addEventListener'); fixture.detectChanges(); - const progressElement = fixture.debugElement.query(By.css('mat-progress-bar')); - expect(progressElement.componentInstance.mode).toBe('buffer'); + expect(primaryValueBar.nativeElement.addEventListener).not.toHaveBeenCalled(); + }); + + it('should trigger the animationEnd output on value set', () => { + fixture.detectChanges(); + spyOn(progressComponent.animationEnd, 'next'); + + progressComponent.value = 40; + expect(progressComponent.animationEnd.next).toHaveBeenCalledWith({ value: 40 }); }); }); }); - @Component({template: ''}) class BasicProgressBar { } diff --git a/src/lib/progress-bar/progress-bar.ts b/src/lib/progress-bar/progress-bar.ts index 506f3d17f46..122995d7640 100644 --- a/src/lib/progress-bar/progress-bar.ts +++ b/src/lib/progress-bar/progress-bar.ts @@ -11,11 +11,19 @@ import { ElementRef, Inject, Input, + Output, + EventEmitter, Optional, + NgZone, ViewEncapsulation, + AfterViewInit, + ViewChild, + OnDestroy, InjectionToken, inject, } from '@angular/core'; +import {fromEvent, Subscription} from 'rxjs'; +import {filter} from 'rxjs/operators'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import {CanColor, mixinColor} from '@angular/material/core'; import {DOCUMENT} from '@angular/common'; @@ -29,6 +37,11 @@ export class MatProgressBarBase { constructor(public _elementRef: ElementRef) { } } +/** Last animation end data. */ +export interface ProgressAnimationEnd { + value: number; +} + export const _MatProgressBarMixinBase = mixinColor(MatProgressBarBase, 'primary'); /** @@ -74,7 +87,7 @@ let progressbarId = 0; '[attr.aria-valuenow]': 'value', '[attr.mode]': 'mode', 'class': 'mat-progress-bar', - '[class._mat-animation-noopable]': `_animationMode === 'NoopAnimations'`, + '[class._mat-animation-noopable]': `_isNoopAnimation`, }, inputs: ['color'], templateUrl: 'progress-bar.html', @@ -82,8 +95,9 @@ let progressbarId = 0; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) -export class MatProgressBar extends _MatProgressBarMixinBase implements CanColor { - constructor(public _elementRef: ElementRef, +export class MatProgressBar extends _MatProgressBarMixinBase implements CanColor, + AfterViewInit, OnDestroy { + constructor(public _elementRef: ElementRef, private _ngZone: NgZone, @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string, /** * @deprecated `location` parameter to be made required. @@ -94,18 +108,30 @@ export class MatProgressBar extends _MatProgressBarMixinBase implements CanColor // We need to prefix the SVG reference with the current path, otherwise they won't work // in Safari if the page has a `` tag. Note that we need quotes inside the `url()`, - // because named route URLs can contain parentheses (see #12338). Also we don't use - // `Location` from `@angular/common` since we can't tell the difference between whether + + // because named route URLs can contain parentheses (see #12338). Also we don't use since + // we can't tell the difference between whether // the consumer is using the hash location strategy or not, because `Location` normalizes // both `/#/foo/bar` and `/foo/bar` to the same thing. const path = location && location.pathname ? location.pathname.split('#')[0] : ''; this._rectangleFillValue = `url('${path}#${this.progressbarId}')`; + this._isNoopAnimation = _animationMode === 'NoopAnimations'; } + /** Flag that indicates whether NoopAnimations mode is set to true. */ + _isNoopAnimation = false; + /** Value of the progress bar. Defaults to zero. Mirrored to aria-valuenow. */ @Input() get value(): number { return this._value; } - set value(v: number) { this._value = clamp(v || 0); } + set value(v: number) { + this._value = clamp(v || 0); + + // When noop animation is set to true, trigger animationEnd directly. + if (this._isNoopAnimation) { + this.emitAnimationEnd(); + } + } private _value: number = 0; /** Buffer value of the progress bar. Defaults to zero. */ @@ -114,6 +140,18 @@ export class MatProgressBar extends _MatProgressBarMixinBase implements CanColor set bufferValue(v: number) { this._bufferValue = clamp(v || 0); } private _bufferValue: number = 0; + @ViewChild('primaryValueBar') _primaryValueBar: ElementRef; + + /** + * Event emitted when animation of the primary progress bar completes. This event will not + * be emitted when animations are disabled, nor will it be emitted for modes with continuous + * animations (indeterminate and query). + */ + @Output() animationEnd = new EventEmitter(); + + /** Reference to animation end subscription to be unsubscribed on destroy. */ + private _animationEndSubscription: Subscription = Subscription.EMPTY; + /** * Mode of the progress bar. * @@ -145,6 +183,31 @@ export class MatProgressBar extends _MatProgressBarMixinBase implements CanColor return {transform: `scaleX(${scale})`}; } } + + ngAfterViewInit() { + if (!this._isNoopAnimation) { + // Run outside angular so change detection didn't get triggered on every transition end + // instead only on the animation that we care about (primary value bar's transitionend) + this._ngZone.runOutsideAngular((() => { + this._animationEndSubscription = + fromEvent(this._primaryValueBar.nativeElement, 'transitionend') + .pipe(filter(((e: TransitionEvent) => + e.target === this._primaryValueBar.nativeElement))) + .subscribe(_ => this._ngZone.run(() => this.emitAnimationEnd())); + })); + } + } + + ngOnDestroy() { + this._animationEndSubscription.unsubscribe(); + } + + /** Emit an animationEnd event if in determinate or buffer mode. */ + private emitAnimationEnd(): void { + if (this.mode === 'determinate' || this.mode === 'buffer') { + this.animationEnd.next({value: this.value}); + } + } } /** Clamps a value to be between two numbers, by default 0 and 100. */