From e0dec929b608b797303c34db1aee52a6a5d5c439 Mon Sep 17 00:00:00 2001 From: Bobbie Goede Date: Fri, 17 May 2024 18:03:48 +0200 Subject: [PATCH] fix: transition `onComplete` triggers after each property animation (#189) --- src/features/eventListeners.ts | 6 ++-- src/useMotionControls.ts | 35 ++++++++++-------- src/utils/transition.ts | 4 --- tests/components.spec.ts | 56 ++++++++++++++--------------- tests/utils/index.ts | 34 ++++++++++++++++++ tests/utils/intersectionObserver.ts | 51 ++++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 49 deletions(-) create mode 100644 tests/utils/index.ts create mode 100644 tests/utils/intersectionObserver.ts diff --git a/src/features/eventListeners.ts b/src/features/eventListeners.ts index 2ec334e..e867674 100644 --- a/src/features/eventListeners.ts +++ b/src/features/eventListeners.ts @@ -83,6 +83,8 @@ export function registerEventListeners (focused.value = false)) } - // Watch local computed variant, apply it dynamically - watch(computedProperties, apply) + // Watch event states, apply it computed properties + watch([hovered, tapped, focused], () => { + apply(computedProperties.value) + }) } diff --git a/src/useMotionControls.ts b/src/useMotionControls.ts index e412368..37517fe 100644 --- a/src/useMotionControls.ts +++ b/src/useMotionControls.ts @@ -46,20 +46,27 @@ export function useMotionControls> // If variant is a key, try to resolve it if (typeof variant === 'string') variant = getVariantFromKey(variant) - // Return Promise chain - return Promise.all( - Object.entries(variant) - .map(([key, value]) => { - // Skip transition key - if (key === 'transition') return undefined - - return new Promise((resolve) => - // @ts-expect-error - Fix errors later for typescript 5 - push(key as keyof MotionProperties, value, motionProperties, (variant as Variant).transition || getDefaultTransition(key, variant[key]), resolve), - ) - }) - .filter(Boolean), - ) + // Create promise chain for each animated property + const animations = Object.entries(variant) + .map(([key, value]) => { + // Skip transition key + if (key === 'transition') return undefined + + return new Promise((resolve) => + // @ts-expect-error - Fix errors later for typescript 5 + push(key as keyof MotionProperties, value, motionProperties, (variant as Variant).transition || getDefaultTransition(key, variant[key]), resolve), + ) + }) + .filter(Boolean) + + // Call `onComplete` after all animations have completed + async function waitForComplete() { + await Promise.all(animations) + ;(variant as Variant).transition?.onComplete?.() + } + + // Return using `Promise.all` to preserve type compatibility + return Promise.all([waitForComplete()]) } const set = (variant: Variant | keyof V) => { diff --git a/src/utils/transition.ts b/src/utils/transition.ts index 1c8b973..debeaeb 100644 --- a/src/utils/transition.ts +++ b/src/utils/transition.ts @@ -221,8 +221,6 @@ export function getAnimation(key: string, value: MotionValue, target: ResolvedVa if (valueTransition.onUpdate) valueTransition.onUpdate(v) }, onComplete: () => { - if (transition.onComplete) transition.onComplete() - if (onComplete) onComplete() if (complete) complete() @@ -236,8 +234,6 @@ export function getAnimation(key: string, value: MotionValue, target: ResolvedVa function set(complete?: () => void): StopAnimation { value.set(target) - if (transition.onComplete) transition.onComplete() - if (onComplete) onComplete() if (complete) complete() diff --git a/tests/components.spec.ts b/tests/components.spec.ts index 64a9466..5354d5b 100644 --- a/tests/components.spec.ts +++ b/tests/components.spec.ts @@ -1,21 +1,9 @@ import { config, mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' -import { h, nextTick } from 'vue' +import { describe, expect, it } from 'vitest' +import { nextTick } from 'vue' import { MotionPlugin } from '../src' -import { MotionComponent } from '../src/components' - -function useCompletionFn() { - return vi.fn(() => {}) -} - -// Get component using either `v-motion` directive or `` component -function getTestComponent(t: string) { - if (t === 'directive') { - return { template: `
Hello world
` } - } - - return { render: () => h(MotionComponent) } -} +import { intersect } from './utils/intersectionObserver' +import { getTestComponent, useCompletionFn, waitForMockCalls } from './utils' // Register plugin config.global.plugins.push(MotionPlugin) @@ -33,6 +21,7 @@ describe.each([ props: { initial: { opacity: 0, x: -100 }, enter: { opacity: 1, x: 0, transition: { onComplete } }, + duration: 10, }, }) @@ -43,21 +32,21 @@ describe.each([ expect(el.style.opacity).toEqual('0') expect(el.style.transform).toEqual('translate3d(-100px,0px,0px)') - await vi.waitUntil(() => onComplete.mock.calls.length === 2) + await waitForMockCalls(onComplete) // Renders enter variant expect(el.style.opacity).toEqual('1') expect(el.style.transform).toEqual('translateZ(0px)') }) - // TODO: not sure intersection observer works using `happy-dom` - it.todo('Visibility variants', async () => { + it('Visibility variants', async () => { const onComplete = useCompletionFn() const wrapper = mount(TestComponent, { props: { - initial: { color: 'red', y: 100 }, + initial: { color: 'red', y: 100, transition: { onComplete } }, visible: { color: 'green', y: 0, transition: { onComplete } }, + duration: 10, }, }) @@ -67,10 +56,19 @@ describe.each([ expect(el.style.color).toEqual('red') expect(el.style.transform).toEqual('translate3d(0px,100px,0px)') - await vi.waitUntil(() => onComplete.mock.calls.length === 2) + // Trigger mock intersection + intersect(el, true) + await waitForMockCalls(onComplete) expect(el.style.color).toEqual('green') - expect(el.style.transform).toEqual('translate3d(0px,0px,0px)') + expect(el.style.transform).toEqual('translateZ(0px)') + + // Trigger mock intersection + intersect(el, false) + await waitForMockCalls(onComplete) + + expect(el.style.color).toEqual('red') + expect(el.style.transform).toEqual('translate3d(0px,100px,0px)') }) it('Event variants', async () => { @@ -82,7 +80,7 @@ describe.each([ hovered: { scale: 1.2, transition: { onComplete } }, tapped: { scale: 1.5, transition: { onComplete } }, focused: { scale: 2, transition: { onComplete } }, - duration: 50, + duration: 10, }, }) @@ -94,37 +92,37 @@ describe.each([ // Trigger hovered await wrapper.trigger('mouseenter') - await vi.waitUntil(() => onComplete.mock.calls.length === 1) + await waitForMockCalls(onComplete) expect(el.style.transform).toEqual('scale(1.2) translateZ(0px)') // Trigger tapped await wrapper.trigger('mousedown') - await vi.waitUntil(() => onComplete.mock.calls.length === 2) + await waitForMockCalls(onComplete) expect(el.style.transform).toEqual('scale(1.5) translateZ(0px)') // Trigger focus await wrapper.trigger('focus') - await vi.waitUntil(() => onComplete.mock.calls.length === 3) + await waitForMockCalls(onComplete) expect(el.style.transform).toEqual('scale(2) translateZ(0px)') // Should return to tapped await wrapper.trigger('blur') - await vi.waitUntil(() => onComplete.mock.calls.length === 4) + await waitForMockCalls(onComplete) expect(el.style.transform).toEqual('scale(1.5) translateZ(0px)') // Should return to hovered await wrapper.trigger('mouseup') - await vi.waitUntil(() => onComplete.mock.calls.length === 5) + await waitForMockCalls(onComplete) expect(el.style.transform).toEqual('scale(1.2) translateZ(0px)') // Should return to initial await wrapper.trigger('mouseleave') - await vi.waitUntil(() => onComplete.mock.calls.length === 6) + await waitForMockCalls(onComplete) expect(el.style.transform).toEqual('scale(1) translateZ(0px)') }) diff --git a/tests/utils/index.ts b/tests/utils/index.ts new file mode 100644 index 0000000..973b14e --- /dev/null +++ b/tests/utils/index.ts @@ -0,0 +1,34 @@ +import { type Mock, vi } from 'vitest' +import { h } from 'vue' +import { MotionComponent } from '../../src/components' + +export function useCompletionFn() { + return vi.fn(() => {}) +} + +// Get component using either `v-motion` directive or `` component +export function getTestComponent(t: string) { + if (t === 'directive') { + return { template: `
Hello world
` } + } + + return { render: () => h(MotionComponent) } +} + +// Waits until mock has been called and resets the call count +export async function waitForMockCalls(fn: Mock, calls = 1, options: Parameters['1'] = { interval: 10 }) { + try { + await vi.waitUntil(() => fn.mock.calls.length === calls, options) + fn.mockReset() + } catch (err) { + // This ensures the vitest error log shows where this helper is called instead of the helper internals + if (err instanceof Error) { + err.message += ` Waited for ${calls} call(s) but failed at ${fn.mock.calls.length} call(s).` + + const arr = err.stack?.split('\n') + arr?.splice(0, 3) + err.stack = arr?.join('\n') ?? undefined + } + throw err + } +} diff --git a/tests/utils/intersectionObserver.ts b/tests/utils/intersectionObserver.ts new file mode 100644 index 0000000..0a504dd --- /dev/null +++ b/tests/utils/intersectionObserver.ts @@ -0,0 +1,51 @@ +// adapted from https://github.com/thebuilder/react-intersection-observer/blob/d35365990136bfbc99ce112270e5ff232cf45f7f/src/test-helper.ts +// and https://jaketrent.com/post/test-intersection-observer-react/ +import { afterEach, beforeEach, vi } from 'vitest' + +const observerMap = new Map() +const instanceMap = new Map() + +beforeEach(() => { + // @ts-expect-error mocked + window.IntersectionObserver = vi.fn((cb, options = {}) => { + const instance = { + thresholds: Array.isArray(options.threshold) ? options.threshold : [options.threshold], + root: options.root, + rootMargin: options.rootMargin, + observe: vi.fn((element: Element) => { + instanceMap.set(element, instance) + observerMap.set(element, cb) + }), + unobserve: vi.fn((element: Element) => { + instanceMap.delete(element) + observerMap.delete(element) + }), + disconnect: vi.fn(), + } + return instance + }) +}) + +afterEach(() => { + // @ts-expect-error mocked + window.IntersectionObserver.mockReset() + instanceMap.clear() + observerMap.clear() +}) + +export function intersect(element: Element, isIntersecting: boolean) { + const cb = observerMap.get(element) + if (cb) { + cb([ + { + isIntersecting, + target: element, + intersectionRatio: isIntersecting ? 1 : -1, + }, + ]) + } +} + +export function getObserverOf(element: Element): IntersectionObserver { + return instanceMap.get(element) +}