Skip to content

Commit d34f9f3

Browse files
authored
refactor(animations): Refactor usage of Web Animations API, add typings (#27)
- Refactor the overall usage of the Web Animations API to use the callback instead of the Promise version for getting notified that the animation has finished -- with this change the default "web-animations-js" polyfill will be sufficient - Add custom typings for the Web Animations API, plus extend the default HTMLElement with the element.animate() method -- more type safety, less usage of 'any' - Refactor unit tests & demo to reflect the changed described above Related to #6, #10.
1 parent d2158bd commit d34f9f3

8 files changed

Lines changed: 130 additions & 62 deletions

src/demo/polyfills.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@ import 'zone.js/dist/zone';
3131

3232
// Web Animations API polyfill
3333
// -> Required for most browser
34-
import 'web-animations-js/web-animations-next.min.js';
34+
import 'web-animations-js';

src/lib/src/components/notifier-notification.component.spec.ts

Lines changed: 32 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import { NotifierTimerService } from '../services/notifier-timer.service';
1616
*/
1717
describe( 'Notifier Notification Component', () => {
1818

19+
// tslint:disable no-any
20+
const fakeAnimation: any = {
21+
onfinish: () => null // We only need this property to be actually mocked away
22+
};
23+
// tslint:enable no-any
24+
1925
const testNotification: NotifierNotification = new NotifierNotification( {
2026
id: 'ID_FAKE',
2127
message: 'Lorem ipsum dolor sit amet.',
@@ -239,16 +245,13 @@ describe( 'Notifier Notification Component', () => {
239245

240246
// Mock away the Web Animations API
241247
jest.spyOn( componentFixture.nativeElement, 'animate' ).mockImplementation( () => {
242-
return {
243-
finished: new Promise<undefined>( ( resolve: () => void, reject: () => void ) => {
244-
componentFixture.debugElement.styles[ 'opacity' ] = '1'; // Fake animation result
245-
resolve();
246-
} )
247-
};
248+
componentFixture.debugElement.styles[ 'opacity' ] = '1'; // Fake animation result
249+
return fakeAnimation;
248250
} );
249251

250252
const showCallback: () => {} = jest.fn();
251253
componentInstance.show().then( showCallback );
254+
fakeAnimation.onfinish();
252255
tick();
253256

254257
expect( componentFixture.debugElement.styles[ 'visibility' ] ).toBe( 'visible' );
@@ -298,16 +301,13 @@ describe( 'Notifier Notification Component', () => {
298301

299302
// Mock away the Web Animations API
300303
jest.spyOn( componentFixture.nativeElement, 'animate' ).mockImplementation( () => {
301-
return {
302-
finished: new Promise<undefined>( ( resolve: () => void, reject: () => void ) => {
303-
componentFixture.debugElement.styles[ 'opacity' ] = '0'; // Fake animation result
304-
resolve();
305-
} )
306-
};
304+
componentFixture.debugElement.styles[ 'opacity' ] = '0'; // Fake animation result
305+
return fakeAnimation;
307306
} );
308307

309308
const hideCallback: () => {} = jest.fn();
310309
componentInstance.hide().then( hideCallback );
310+
fakeAnimation.onfinish();
311311
tick();
312312

313313
expect( componentFixture.debugElement.styles[ 'opacity' ] ).toBe( '0' );
@@ -385,17 +385,14 @@ describe( 'Notifier Notification Component', () => {
385385

386386
// Mock away the Web Animations API
387387
jest.spyOn( componentFixture.nativeElement, 'animate' ).mockImplementation( () => {
388-
return {
389-
finished: new Promise<undefined>( ( resolve: () => void, reject: () => void ) => {
390-
componentFixture.debugElement.styles[ 'transform' ] =
391-
`translate3d( 0, ${ shiftDistance + testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
392-
resolve();
393-
} )
394-
};
388+
componentFixture.debugElement.styles[ 'transform' ] =
389+
`translate3d( 0, ${ shiftDistance + testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
390+
return fakeAnimation;
395391
} );
396392

397393
const shiftCallback: () => {} = jest.fn();
398394
componentInstance.shift( shiftDistance, true ).then( shiftCallback );
395+
fakeAnimation.onfinish();
399396
tick();
400397

401398
expect( componentFixture.debugElement.styles[ 'transform' ] )
@@ -469,17 +466,14 @@ describe( 'Notifier Notification Component', () => {
469466
// Mock away the Web Animations API
470467
const shiftDistance: number = 100;
471468
jest.spyOn( componentFixture.nativeElement, 'animate' ).mockImplementation( () => {
472-
return {
473-
finished: new Promise<undefined>( ( resolve: () => void, reject: () => void ) => {
474-
componentFixture.debugElement.styles[ 'transform' ] =
475-
`translate3d( 0, ${ -shiftDistance - testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
476-
resolve();
477-
} )
478-
};
469+
componentFixture.debugElement.styles[ 'transform' ] =
470+
`translate3d( 0, ${ -shiftDistance - testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
471+
return fakeAnimation;
479472
} );
480473

481474
const shiftCallback: () => {} = jest.fn();
482475
componentInstance.shift( shiftDistance, true ).then( shiftCallback );
476+
fakeAnimation.onfinish();
483477
tick();
484478

485479
expect( componentFixture.debugElement.styles[ 'transform' ] )
@@ -553,17 +547,14 @@ describe( 'Notifier Notification Component', () => {
553547
// Mock away the Web Animations API
554548
const shiftDistance: number = 100;
555549
jest.spyOn( componentFixture.nativeElement, 'animate' ).mockImplementation( () => {
556-
return {
557-
finished: new Promise<undefined>( ( resolve: () => void, reject: () => void ) => {
558-
componentFixture.debugElement.styles[ 'transform' ] =
559-
`translate3d( 0, ${ -shiftDistance - testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
560-
resolve();
561-
} )
562-
};
550+
componentFixture.debugElement.styles[ 'transform' ] =
551+
`translate3d( 0, ${ -shiftDistance - testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
552+
return fakeAnimation;
563553
} );
564554

565555
const shiftCallback: () => {} = jest.fn();
566556
componentInstance.shift( shiftDistance, false ).then( shiftCallback );
557+
fakeAnimation.onfinish();
567558
tick();
568559

569560
expect( componentFixture.debugElement.styles[ 'transform' ] )
@@ -637,17 +628,14 @@ describe( 'Notifier Notification Component', () => {
637628
// Mock away the Web Animations API
638629
const shiftDistance: number = 100;
639630
jest.spyOn( componentFixture.nativeElement, 'animate' ).mockImplementation( () => {
640-
return {
641-
finished: new Promise<undefined>( ( resolve: () => void, reject: () => void ) => {
642-
componentFixture.debugElement.styles[ 'transform' ] =
643-
`translate3d( 0, ${ shiftDistance + testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
644-
resolve();
645-
} )
646-
};
631+
componentFixture.debugElement.styles[ 'transform' ] =
632+
`translate3d( 0, ${ shiftDistance + testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
633+
return fakeAnimation;
647634
} );
648635

649636
const shiftCallback: () => {} = jest.fn();
650637
componentInstance.shift( shiftDistance, false ).then( shiftCallback );
638+
fakeAnimation.onfinish();
651639
tick();
652640

653641
expect( componentFixture.debugElement.styles[ 'transform' ] )
@@ -724,17 +712,14 @@ describe( 'Notifier Notification Component', () => {
724712
// Mock away the Web Animations API
725713
const shiftDistance: number = 100;
726714
jest.spyOn( componentFixture.nativeElement, 'animate' ).mockImplementation( () => {
727-
return {
728-
finished: new Promise<undefined>( ( resolve: () => void, reject: () => void ) => {
729-
componentFixture.debugElement.styles[ 'transform' ] =
730-
`translate3d( -50%, ${ shiftDistance + testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
731-
resolve();
732-
} )
733-
};
715+
componentFixture.debugElement.styles[ 'transform' ] =
716+
`translate3d( -50%, ${ shiftDistance + testNotifierConfig.position.vertical.gap }px, 0 )`; // Fake animation result
717+
return fakeAnimation;
734718
} );
735719

736720
const shiftCallback: () => {} = jest.fn();
737721
componentInstance.shift( shiftDistance, true ).then( shiftCallback );
722+
fakeAnimation.onfinish();
738723
tick();
739724

740725
expect( componentFixture.debugElement.styles[ 'transform' ] )

src/lib/src/components/notifier-notification.component.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,10 @@ export class NotifierNotificationComponent implements AfterViewInit {
7171
*/
7272
private readonly renderer: Renderer2;
7373

74-
// tslint:disable no-any
7574
/**
7675
* Native element reference, used for manipulating DOM properties
7776
*/
78-
private readonly element: any; // Similar to an HTMLElement, but also includes web animations properties / methods
79-
// tslint:enable no-any
77+
private readonly element: HTMLElement;
8078

8179
/**
8280
* Current notification height, calculated and cached here (#perfmatters)
@@ -183,10 +181,11 @@ export class NotifierNotificationComponent implements AfterViewInit {
183181

184182
// Animate notification in
185183
this.renderer.setStyle( this.element, 'visibility', 'visible' );
186-
this.element.animate( animationData.keyframes, animationData.options ).finished.then( () => {
184+
const animation: Animation = this.element.animate( animationData.keyframes, animationData.options );
185+
animation.onfinish = () => {
187186
this.startAutoHideTimer();
188187
resolve(); // Done
189-
} );
188+
};
190189

191190
} else {
192191

@@ -214,7 +213,10 @@ export class NotifierNotificationComponent implements AfterViewInit {
214213
// Are animations enabled?
215214
if ( this.config.animations.enabled && this.config.animations.hide.speed > 0 ) {
216215
const animationData: NotifierAnimationData = this.animationService.getAnimationData( 'hide', this.notification );
217-
this.element.animate( animationData.keyframes, animationData.options ).finished.then( resolve ); // Done
216+
const animation: Animation = this.element.animate( animationData.keyframes, animationData.options );
217+
animation.onfinish = () => {
218+
resolve(); // Done
219+
};
218220
} else {
219221
resolve(); // Done
220222
}
@@ -260,7 +262,11 @@ export class NotifierNotificationComponent implements AfterViewInit {
260262
}
261263
};
262264
this.elementShift = newElementShift;
263-
this.element.animate( animationData.keyframes, animationData.options ).finished.then( resolve ); // Done
265+
const animation: Animation = this.element.animate( animationData.keyframes, animationData.options );
266+
animation.onfinish = () => {
267+
resolve(); // Done
268+
};
269+
264270
} else {
265271
this.renderer.setStyle( this.element, 'transform', `translate3d( ${ horizontalPosition }, ${ newElementShift }px, 0 )` );
266272
this.elementShift = newElementShift;

src/lib/src/models/notifier-animation.model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ export interface NotifierAnimationData {
3232
/**
3333
* Animation easing function (comp. CSS easing functions)
3434
*/
35-
easing: string;
35+
easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | string;
3636

3737
/**
3838
* Animation fill mode
3939
*/
40-
fill: string;
40+
fill: 'none' | 'forwards' | 'backwards';
4141

4242
};
4343

src/lib/typings.d.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* This file contains custom typings for this library.
3+
*
4+
* Sadly, while TypeScript comes with a very rich set of typings for JavaScript and the DOM, some things are missing, including offset
5+
* width & height values on the HTMLElement, the animate() method on the Element plus various Web Animations API related interfaces. To
6+
* reduce the amount of 'any' usage in those cases, the following typings are defined to (hopefully only temporarily) help us out.
7+
*
8+
* Typings related to the Web Animations API are guessed based on the official MDN documentation. They should be complete on the first
9+
* level, deeper levels -- and thus functionality not used by this library -- is not typed in further details, but declared as type 'any'.
10+
* For details, see <https://developer.mozilla.org/en-US/docs/Web/API/Element/animate>.
11+
*/
12+
13+
/**
14+
* Extended HTMLElement
15+
*
16+
* We can simply extend the default TypeScript definitions as TypeScript interfaces are *open ended*.
17+
* See <https://github.com/basarat/typescript-book/blob/master/docs/types/interfaces.md>.
18+
*/
19+
interface HTMLElement {
20+
// offsetHeight: number;
21+
// offsetWidth: number;
22+
animate: ( keyframes: AnimationKeyframes, options?: AnimationOptions | number ) => Animation;
23+
}
24+
25+
/**
26+
* Animation keyframe
27+
*/
28+
type AnimationKeyframe = { [ animatablePropertyName: string ]: string };
29+
30+
/**
31+
* Animation Keyframes
32+
*/
33+
type AnimationKeyframes = Array<AnimationKeyframe>;
34+
35+
/**
36+
* Animation options
37+
*/
38+
interface AnimationOptions {
39+
id?: string;
40+
delay?: number;
41+
direction?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse';
42+
duration?: number;
43+
easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | string;
44+
endDelay?: number;
45+
fill?: 'none' | 'forwards' | 'backwards';
46+
iterationStart?: number;
47+
iterations?: number;
48+
}
49+
50+
/**
51+
* Animation object, returned by element.animate()
52+
*/
53+
interface Animation {
54+
currentTime: number | null;
55+
// tslint:disable no-any
56+
effect: any | null; // No deeper type details needed
57+
// tslint:enable no-any
58+
readonly finished: Promise<undefined>;
59+
id: string | null;
60+
readonly playState: 'idle' | 'pending' | 'running' | 'paused' | 'finished';
61+
playbackRate: number;
62+
readonly ready: Promise<undefined>;
63+
startTime: number | null;
64+
// tslint:disable no-any
65+
timeline: any | null; // No deeper type details needed
66+
// tslint:enable no-any
67+
68+
oncancel: () => void | null;
69+
onfinish: () => void | null;
70+
71+
cancel: () => void;
72+
finish: () => void;
73+
pause: () => void;
74+
play: () => void;
75+
reverse: () => void;
76+
}

tools/gulp/ts/ts-inline-resources.task.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,8 @@ gulp.task( 'ts:inline-resources', () => {
5454

5555
return gulp
5656
.src( [
57-
'src/lib/**/*.ts',
58-
'!src/lib/**/*.spec.ts',
59-
'!src/lib/**/*.d.ts'
57+
'src/lib/**/*.ts', // This includes custom typings
58+
'!src/lib/**/*.spec.ts'
6059
] )
6160
.pipe( inlineTemplate( inlineTemplateOptions ) )
6261
.pipe( gulp.dest( 'build/library-inline' ) );

tools/tsc/tsconfig.es2015.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"types": []
1111
},
1212
"files": [
13-
"./../../build/library-inline/index.ts"
13+
"./../../build/library-inline/index.ts",
14+
"./../../build/library-inline/typings.d.ts"
1415
],
1516
"angularCompilerOptions": {
1617
"annotateForClosureCompiler": true,

tools/tsc/tsconfig.es5.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"types": []
1010
},
1111
"files": [
12-
"./../../build/library-inline/index.ts"
12+
"./../../build/library-inline/index.ts",
13+
"./../../build/library-inline/typings.d.ts"
1314
],
1415
"angularCompilerOptions": {
1516
"annotateForClosureCompiler": true,

0 commit comments

Comments
 (0)