Skip to content

Commit

Permalink
fix: transition onComplete triggers after each property animation (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
BobbieGoede committed May 17, 2024
1 parent 5e2346a commit e0dec92
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 49 deletions.
6 changes: 4 additions & 2 deletions src/features/eventListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export function registerEventListeners<T extends string, V extends MotionVariant
useEventListener(target as any, 'blur', () => (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)
})
}
35 changes: 21 additions & 14 deletions src/useMotionControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,27 @@ export function useMotionControls<T extends string, V extends MotionVariants<T>>
// 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<void>((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<void>((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) => {
Expand Down
4 changes: 0 additions & 4 deletions src/utils/transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
56 changes: 27 additions & 29 deletions tests/components.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<Motion>` component
function getTestComponent(t: string) {
if (t === 'directive') {
return { template: `<div v-motion>Hello world</div>` }
}

return { render: () => h(MotionComponent) }
}
import { intersect } from './utils/intersectionObserver'
import { getTestComponent, useCompletionFn, waitForMockCalls } from './utils'

// Register plugin
config.global.plugins.push(MotionPlugin)
Expand All @@ -33,6 +21,7 @@ describe.each([
props: {
initial: { opacity: 0, x: -100 },
enter: { opacity: 1, x: 0, transition: { onComplete } },
duration: 10,
},
})

Expand All @@ -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,
},
})

Expand All @@ -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 () => {
Expand All @@ -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,
},
})

Expand All @@ -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)')
})
Expand Down
34 changes: 34 additions & 0 deletions tests/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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 `<Motion>` component
export function getTestComponent(t: string) {
if (t === 'directive') {
return { template: `<div v-motion>Hello world</div>` }
}

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<typeof vi.waitUntil>['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
}
}
51 changes: 51 additions & 0 deletions tests/utils/intersectionObserver.ts
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit e0dec92

Please sign in to comment.