Repository: swup/swup
Pull request #923 (2024-07-14 18:29:52)

Clean up animation types
**Description**

- Use strict types for animation end event handler
- Rename types for singular to match TypeScript naming
- Remove unnecessary type guard that was only introduced to make the compiler happy
- Simplify style declaration types

**Drive-by**

- Add missing unit tests for animation helpers

**Checks**

- [x] The PR is submitted to the `master` branch
- [x] The code was linted before pushing (`npm run lint`)
- [x] All tests are passing (`npm run test`)
- [x] New or updated tests are included
- [ ] ~~The documentation was updated as required~~


Patch:
diff --git a/src/modules/awaitAnimations.ts b/src/modules/awaitAnimations.ts
index 1c36f87..e4b77e7 100644
--- a/src/modules/awaitAnimations.ts
+++ b/src/modules/awaitAnimations.ts
@@ -5,10 +5,10 @@ import type { Options } from '../Swup.js';
 const TRANSITION = 'transition';
 const ANIMATION = 'animation';
 
-type AnimationTypes = typeof TRANSITION | typeof ANIMATION;
-type AnimationProperties = 'Delay' | 'Duration';
-type AnimationStyleKeys = `${AnimationTypes}${AnimationProperties}` | 'transitionProperty';
-type AnimationStyleDeclarations = Pick<CSSStyleDeclaration, AnimationStyleKeys>;
+type AnimationType = typeof TRANSITION | typeof ANIMATION;
+type AnimationEndEvent = `${AnimationType}end`;
+type AnimationProperty = 'Delay' | 'Duration';
+type AnimationStyleKey = `${AnimationType}${AnimationProperty}` | 'transitionProperty';
 
 export type AnimationDirection = 'in' | 'out';
 
@@ -19,8 +19,8 @@ export type AnimationDirection = 'in' | 'out';
 export async function awaitAnimations(
 	this: Swup,
 	{
-		elements,
-		selector
+		selector,
+		elements
 	}: {
 		selector: Options['animationSelector'];
 		elements?: NodeListOf<HTMLElement> | HTMLElement[];
@@ -58,7 +58,7 @@ export async function awaitAnimations(
 	await Promise.all(awaitedAnimations);
 }
 
-function awaitAnimationsOnElement(element: Element): Promise<void> | false {
+function awaitAnimationsOnElement(element: HTMLElement): Promise<void> | false {
 	const { type, timeout, propCount } = getTransitionInfo(element);
 
 	// Resolve immediately if no transition defined
@@ -67,7 +67,7 @@ function awaitAnimationsOnElement(element: Element): Promise<void> | false {
 	}
 
 	return new Promise((resolve) => {
-		const endEvent = `${type}end`;
+		const endEvent: AnimationEndEvent = `${type}end`;
 		const startTime = performance.now();
 		let propsTransitioned = 0;
 
@@ -76,16 +76,12 @@ function awaitAnimationsOnElement(element: Element): Promise<void> | false {
 			resolve();
 		};
 
-		const onEnd: EventListener = (event) => {
+		const onEnd = (event: TransitionEvent | AnimationEvent) => {
 			// Skip transitions on child elements
 			if (event.target !== element) {
 				return;
 			}
 
-			if (!isTransitionOrAnimationEvent(event)) {
-				throw new Error('Not a transition or animation event.');
-			}
-
 			// Skip transitions that happened before we started listening
 			const elapsedTime = (performance.now() - startTime) / 1000;
 			if (elapsedTime < event.elapsedTime) {
@@ -109,17 +105,18 @@ function awaitAnimationsOnElement(element: Element): Promise<void> | false {
 }
 
 function getTransitionInfo(element: Element) {
-	const styles = window.getComputedStyle(element) as AnimationStyleDeclarations;
+	const styles = window.getComputedStyle(element);
 
 	const transitionDelays = getStyleProperties(styles, `${TRANSITION}Delay`);
 	const transitionDurations = getStyleProperties(styles, `${TRANSITION}Duration`);
 	const transitionTimeout = calculateTimeout(transitionDelays, transitionDurations);
+
 	const animationDelays = getStyleProperties(styles, `${ANIMATION}Delay`);
 	const animationDurations = getStyleProperties(styles, `${ANIMATION}Duration`);
 	const animationTimeout = calculateTimeout(animationDelays, animationDurations);
 
 	const timeout = Math.max(transitionTimeout, animationTimeout);
-	const type =
+	const type: AnimationType | null =
 		timeout > 0 ? (transitionTimeout > animationTimeout ? TRANSITION : ANIMATION) : null;
 	const propCount = type
 		? type === TRANSITION
@@ -134,15 +131,11 @@ function getTransitionInfo(element: Element) {
 	};
 }
 
-function isTransitionOrAnimationEvent(event: Event): event is TransitionEvent | AnimationEvent {
-	return [`${TRANSITION}end`, `${ANIMATION}end`].includes(event.type);
-}
-
-function getStyleProperties(styles: AnimationStyleDeclarations, key: AnimationStyleKeys): string[] {
+export function getStyleProperties(styles: CSSStyleDeclaration, key: AnimationStyleKey): string[] {
 	return (styles[key] || '').split(', ');
 }
 
-function calculateTimeout(delays: string[], durations: string[]): number {
+export function calculateTimeout(delays: string[], durations: string[]): number {
 	while (delays.length < durations.length) {
 		delays = delays.concat(delays);
 	}
@@ -150,6 +143,6 @@ function calculateTimeout(delays: string[], durations: string[]): number {
 	return Math.max(...durations.map((duration, i) => toMs(duration) + toMs(delays[i])));
 }
 
-function toMs(time: string): number {
+export function toMs(time: string): number {
 	return parseFloat(time) * 1000;
 }


Test patch:
diff --git a/tests/unit/awaitAnimations.test.ts b/tests/unit/awaitAnimations.test.ts
new file mode 100644
index 0000000..713fcee
--- /dev/null
+++ b/tests/unit/awaitAnimations.test.ts
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import Swup from '../../src/index.js';
+import { calculateTimeout, getStyleProperties, toMs } from '../../src/modules/awaitAnimations.js';
+
+describe('awaitAnimations', () => {
+	it('returns a Promise', () => {
+		const swup = new Swup();
+		expect(swup.awaitAnimations({ selector: 'main' })).toBeInstanceOf(Promise);
+		expect(swup.awaitAnimations({ selector: false })).toBeInstanceOf(Promise);
+		expect(swup.awaitAnimations({ selector: false, elements: [] })).toBeInstanceOf(Promise);
+	});
+});
+
+describe('getStyleProperties', () => {
+	let element: HTMLElement;
+
+	beforeEach(() => {
+		element = document.createElement('div');
+	});
+
+	it('returns an array', () => {
+		const style = window.getComputedStyle(element);
+		expect(getStyleProperties(style, 'transitionDelay')).toBeInstanceOf(Array);
+	});
+
+	it('gets correct element style', () => {
+		element.style.transitionDuration = '1s';
+		const style = window.getComputedStyle(element);
+		expect(getStyleProperties(style, 'transitionDuration')).toEqual(['1s']);
+		expect(getStyleProperties(style, 'animationDuration')).toEqual(['']);
+	});
+
+	it('splits style declaration by comma', () => {
+		element.style.transitionDelay = '1s, 2s';
+		const style = window.getComputedStyle(element);
+		expect(getStyleProperties(style, 'transitionDelay')).toEqual(['1s', '2s']);
+	});
+});
+
+describe('calculateTimeout', () => {
+	it('returns a number', () => {
+		expect(typeof calculateTimeout(['1000ms'], ['0ms'])).toBe('number');
+	});
+
+	it('converts to milliseconds', () => {
+		expect(calculateTimeout(['1s'], ['0s'])).toEqual(1000);
+	});
+
+	it('adds delay to duration', () => {
+		expect(calculateTimeout(['1s'], ['3s'])).toEqual(4000);
+	});
+
+	it('uses highest value', () => {
+		expect(calculateTimeout(['1s', '2s'], ['1s', '2s'])).toEqual(4000);
+		expect(calculateTimeout(['1s', '2s'], ['4s', '1s'])).toEqual(5000);
+		expect(calculateTimeout(['3s', '2s'], ['2s', '4s'])).toEqual(6000);
+	});
+});
+
+describe('toMs', () => {
+	it('returns a number', () => {
+		expect(typeof toMs('1000s')).toBe('number');
+	});
+
+	it('converts to milliseconds', () => {
+		expect(toMs('1s')).toBe(1000);
+		expect(toMs('2s')).toBe(2000);
+		expect(toMs('10s')).toBe(10000);
+	});
+
+	it('ignores units', () => {
+		expect(toMs('1ms')).toBe(1000);
+		expect(toMs('2sec')).toBe(2000);
+		expect(toMs(10 as any as string)).toBe(10000);
+	});
+});