feat(progress-bar): add transitionend output for value animation (#12409)

This commit is contained in:
Louis Chandra
2018-08-28 07:59:40 -07:00
committed by Jeremy Elbourn
parent f82a94bd51
commit 6a1a70753e
5 changed files with 267 additions and 101 deletions
@@ -8,12 +8,15 @@
<div class="demo-progress-bar-controls">
<span>Value: {{determinateProgressValue}}</span>
<br/>
<span>Last animation complete value: {{determinateAnimationEndValue}}</span>
<button mat-raised-button (click)="stepDeterminateProgressVal(10)">Increase</button>
<button mat-raised-button (click)="stepDeterminateProgressVal(-10)">Decrease</button>
</div>
<div class="demo-progress-bar-container">
<mat-progress-bar mode="determinate" [value]="determinateProgressValue" [color]="color">
<mat-progress-bar mode="determinate" [value]="determinateProgressValue" [color]="color"
(animationEnd)="determinateAnimationEndValue = $event.value">
</mat-progress-bar>
</div>
@@ -21,6 +24,8 @@
<div class="demo-progress-bar-controls">
<span>Value: {{bufferProgressValue}}</span>
<br/>
<span>Last animation complete value: {{bufferAnimationEndValue}}</span>
<button mat-raised-button (click)="stepBufferProgressVal(10)">Increase</button>
<button mat-raised-button (click)="stepBufferProgressVal(-10)">Decrease</button>
<span class="demo-progress-bar-spacer"></span>
@@ -31,7 +36,8 @@
<div class="demo-progress-bar-container">
<mat-progress-bar [value]="bufferProgressValue" [bufferValue]="bufferBufferValue" mode="buffer"
[color]="color"></mat-progress-bar>
[color]="color" (animationEnd)="bufferAnimationEndValue = $event.value">
</mat-progress-bar>
</div>
<h1>Indeterminate</h1>
@@ -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;
+1 -1
View File
@@ -11,5 +11,5 @@
<rect [attr.fill]="_rectangleFillValue" width="100%" height="100%"/>
</svg>
<div class="mat-progress-bar-buffer mat-progress-bar-element" [ngStyle]="_bufferTransform()"></div>
<div class="mat-progress-bar-primary mat-progress-bar-fill mat-progress-bar-element" [ngStyle]="_primaryTransform()"></div>
<div class="mat-progress-bar-primary mat-progress-bar-fill mat-progress-bar-element" [ngStyle]="_primaryTransform()" #primaryValueBar></div>
<div class="mat-progress-bar-secondary mat-progress-bar-fill mat-progress-bar-element"></div>

Before

Width:  |  Height:  |  Size: 796 B

After

Width:  |  Height:  |  Size: 813 B

+187 -92
View File
@@ -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<T>(componentType: Type<T>): ComponentFixture<T> {
function createComponent<T>(componentType: Type<T>,
imports?: Array<Type<{}>>): ComponentFixture<T> {
TestBed.configureTestingModule({
imports: [MatProgressBarModule],
imports: imports || [MatProgressBarModule],
declarations: [componentType],
providers: [{
provide: MAT_PROGRESS_BAR_LOCATION,
@@ -20,119 +24,210 @@ describe('MatProgressBar', () => {
return TestBed.createComponent<T>(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<BasicProgressBar>;
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<BufferProgressBar>;
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<BasicProgressBar>;
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: '<mat-progress-bar></mat-progress-bar>'})
class BasicProgressBar { }
+69 -6
View File
@@ -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 `<base>` 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<ProgressAnimationEnd>();
/** 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. */