Skip to content

Commit e0dec92

Browse files
authored
fix: transition onComplete triggers after each property animation (#189)
1 parent 5e2346a commit e0dec92

File tree

6 files changed

+137
-49
lines changed

6 files changed

+137
-49
lines changed

src/features/eventListeners.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export function registerEventListeners<T extends string, V extends MotionVariant
8383
useEventListener(target as any, 'blur', () => (focused.value = false))
8484
}
8585

86-
// Watch local computed variant, apply it dynamically
87-
watch(computedProperties, apply)
86+
// Watch event states, apply it computed properties
87+
watch([hovered, tapped, focused], () => {
88+
apply(computedProperties.value)
89+
})
8890
}

src/useMotionControls.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,27 @@ export function useMotionControls<T extends string, V extends MotionVariants<T>>
4646
// If variant is a key, try to resolve it
4747
if (typeof variant === 'string') variant = getVariantFromKey(variant)
4848

49-
// Return Promise chain
50-
return Promise.all(
51-
Object.entries(variant)
52-
.map(([key, value]) => {
53-
// Skip transition key
54-
if (key === 'transition') return undefined
55-
56-
return new Promise<void>((resolve) =>
57-
// @ts-expect-error - Fix errors later for typescript 5
58-
push(key as keyof MotionProperties, value, motionProperties, (variant as Variant).transition || getDefaultTransition(key, variant[key]), resolve),
59-
)
60-
})
61-
.filter(Boolean),
62-
)
49+
// Create promise chain for each animated property
50+
const animations = Object.entries(variant)
51+
.map(([key, value]) => {
52+
// Skip transition key
53+
if (key === 'transition') return undefined
54+
55+
return new Promise<void>((resolve) =>
56+
// @ts-expect-error - Fix errors later for typescript 5
57+
push(key as keyof MotionProperties, value, motionProperties, (variant as Variant).transition || getDefaultTransition(key, variant[key]), resolve),
58+
)
59+
})
60+
.filter(Boolean)
61+
62+
// Call `onComplete` after all animations have completed
63+
async function waitForComplete() {
64+
await Promise.all(animations)
65+
;(variant as Variant).transition?.onComplete?.()
66+
}
67+
68+
// Return using `Promise.all` to preserve type compatibility
69+
return Promise.all([waitForComplete()])
6370
}
6471

6572
const set = (variant: Variant | keyof V) => {

src/utils/transition.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,6 @@ export function getAnimation(key: string, value: MotionValue, target: ResolvedVa
221221
if (valueTransition.onUpdate) valueTransition.onUpdate(v)
222222
},
223223
onComplete: () => {
224-
if (transition.onComplete) transition.onComplete()
225-
226224
if (onComplete) onComplete()
227225

228226
if (complete) complete()
@@ -236,8 +234,6 @@ export function getAnimation(key: string, value: MotionValue, target: ResolvedVa
236234
function set(complete?: () => void): StopAnimation {
237235
value.set(target)
238236

239-
if (transition.onComplete) transition.onComplete()
240-
241237
if (onComplete) onComplete()
242238

243239
if (complete) complete()

tests/components.spec.ts

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,9 @@
11
import { config, mount } from '@vue/test-utils'
2-
import { describe, expect, it, vi } from 'vitest'
3-
import { h, nextTick } from 'vue'
2+
import { describe, expect, it } from 'vitest'
3+
import { nextTick } from 'vue'
44
import { MotionPlugin } from '../src'
5-
import { MotionComponent } from '../src/components'
6-
7-
function useCompletionFn() {
8-
return vi.fn(() => {})
9-
}
10-
11-
// Get component using either `v-motion` directive or `<Motion>` component
12-
function getTestComponent(t: string) {
13-
if (t === 'directive') {
14-
return { template: `<div v-motion>Hello world</div>` }
15-
}
16-
17-
return { render: () => h(MotionComponent) }
18-
}
5+
import { intersect } from './utils/intersectionObserver'
6+
import { getTestComponent, useCompletionFn, waitForMockCalls } from './utils'
197

208
// Register plugin
219
config.global.plugins.push(MotionPlugin)
@@ -33,6 +21,7 @@ describe.each([
3321
props: {
3422
initial: { opacity: 0, x: -100 },
3523
enter: { opacity: 1, x: 0, transition: { onComplete } },
24+
duration: 10,
3625
},
3726
})
3827

@@ -43,21 +32,21 @@ describe.each([
4332
expect(el.style.opacity).toEqual('0')
4433
expect(el.style.transform).toEqual('translate3d(-100px,0px,0px)')
4534

46-
await vi.waitUntil(() => onComplete.mock.calls.length === 2)
35+
await waitForMockCalls(onComplete)
4736

4837
// Renders enter variant
4938
expect(el.style.opacity).toEqual('1')
5039
expect(el.style.transform).toEqual('translateZ(0px)')
5140
})
5241

53-
// TODO: not sure intersection observer works using `happy-dom`
54-
it.todo('Visibility variants', async () => {
42+
it('Visibility variants', async () => {
5543
const onComplete = useCompletionFn()
5644

5745
const wrapper = mount(TestComponent, {
5846
props: {
59-
initial: { color: 'red', y: 100 },
47+
initial: { color: 'red', y: 100, transition: { onComplete } },
6048
visible: { color: 'green', y: 0, transition: { onComplete } },
49+
duration: 10,
6150
},
6251
})
6352

@@ -67,10 +56,19 @@ describe.each([
6756
expect(el.style.color).toEqual('red')
6857
expect(el.style.transform).toEqual('translate3d(0px,100px,0px)')
6958

70-
await vi.waitUntil(() => onComplete.mock.calls.length === 2)
59+
// Trigger mock intersection
60+
intersect(el, true)
61+
await waitForMockCalls(onComplete)
7162

7263
expect(el.style.color).toEqual('green')
73-
expect(el.style.transform).toEqual('translate3d(0px,0px,0px)')
64+
expect(el.style.transform).toEqual('translateZ(0px)')
65+
66+
// Trigger mock intersection
67+
intersect(el, false)
68+
await waitForMockCalls(onComplete)
69+
70+
expect(el.style.color).toEqual('red')
71+
expect(el.style.transform).toEqual('translate3d(0px,100px,0px)')
7472
})
7573

7674
it('Event variants', async () => {
@@ -82,7 +80,7 @@ describe.each([
8280
hovered: { scale: 1.2, transition: { onComplete } },
8381
tapped: { scale: 1.5, transition: { onComplete } },
8482
focused: { scale: 2, transition: { onComplete } },
85-
duration: 50,
83+
duration: 10,
8684
},
8785
})
8886

@@ -94,37 +92,37 @@ describe.each([
9492

9593
// Trigger hovered
9694
await wrapper.trigger('mouseenter')
97-
await vi.waitUntil(() => onComplete.mock.calls.length === 1)
95+
await waitForMockCalls(onComplete)
9896

9997
expect(el.style.transform).toEqual('scale(1.2) translateZ(0px)')
10098

10199
// Trigger tapped
102100
await wrapper.trigger('mousedown')
103-
await vi.waitUntil(() => onComplete.mock.calls.length === 2)
101+
await waitForMockCalls(onComplete)
104102

105103
expect(el.style.transform).toEqual('scale(1.5) translateZ(0px)')
106104

107105
// Trigger focus
108106
await wrapper.trigger('focus')
109-
await vi.waitUntil(() => onComplete.mock.calls.length === 3)
107+
await waitForMockCalls(onComplete)
110108

111109
expect(el.style.transform).toEqual('scale(2) translateZ(0px)')
112110

113111
// Should return to tapped
114112
await wrapper.trigger('blur')
115-
await vi.waitUntil(() => onComplete.mock.calls.length === 4)
113+
await waitForMockCalls(onComplete)
116114

117115
expect(el.style.transform).toEqual('scale(1.5) translateZ(0px)')
118116

119117
// Should return to hovered
120118
await wrapper.trigger('mouseup')
121-
await vi.waitUntil(() => onComplete.mock.calls.length === 5)
119+
await waitForMockCalls(onComplete)
122120

123121
expect(el.style.transform).toEqual('scale(1.2) translateZ(0px)')
124122

125123
// Should return to initial
126124
await wrapper.trigger('mouseleave')
127-
await vi.waitUntil(() => onComplete.mock.calls.length === 6)
125+
await waitForMockCalls(onComplete)
128126

129127
expect(el.style.transform).toEqual('scale(1) translateZ(0px)')
130128
})

tests/utils/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { type Mock, vi } from 'vitest'
2+
import { h } from 'vue'
3+
import { MotionComponent } from '../../src/components'
4+
5+
export function useCompletionFn() {
6+
return vi.fn(() => {})
7+
}
8+
9+
// Get component using either `v-motion` directive or `<Motion>` component
10+
export function getTestComponent(t: string) {
11+
if (t === 'directive') {
12+
return { template: `<div v-motion>Hello world</div>` }
13+
}
14+
15+
return { render: () => h(MotionComponent) }
16+
}
17+
18+
// Waits until mock has been called and resets the call count
19+
export async function waitForMockCalls(fn: Mock, calls = 1, options: Parameters<typeof vi.waitUntil>['1'] = { interval: 10 }) {
20+
try {
21+
await vi.waitUntil(() => fn.mock.calls.length === calls, options)
22+
fn.mockReset()
23+
} catch (err) {
24+
// This ensures the vitest error log shows where this helper is called instead of the helper internals
25+
if (err instanceof Error) {
26+
err.message += ` Waited for ${calls} call(s) but failed at ${fn.mock.calls.length} call(s).`
27+
28+
const arr = err.stack?.split('\n')
29+
arr?.splice(0, 3)
30+
err.stack = arr?.join('\n') ?? undefined
31+
}
32+
throw err
33+
}
34+
}

tests/utils/intersectionObserver.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// adapted from https://github.com/thebuilder/react-intersection-observer/blob/d35365990136bfbc99ce112270e5ff232cf45f7f/src/test-helper.ts
2+
// and https://jaketrent.com/post/test-intersection-observer-react/
3+
import { afterEach, beforeEach, vi } from 'vitest'
4+
5+
const observerMap = new Map()
6+
const instanceMap = new Map()
7+
8+
beforeEach(() => {
9+
// @ts-expect-error mocked
10+
window.IntersectionObserver = vi.fn((cb, options = {}) => {
11+
const instance = {
12+
thresholds: Array.isArray(options.threshold) ? options.threshold : [options.threshold],
13+
root: options.root,
14+
rootMargin: options.rootMargin,
15+
observe: vi.fn((element: Element) => {
16+
instanceMap.set(element, instance)
17+
observerMap.set(element, cb)
18+
}),
19+
unobserve: vi.fn((element: Element) => {
20+
instanceMap.delete(element)
21+
observerMap.delete(element)
22+
}),
23+
disconnect: vi.fn(),
24+
}
25+
return instance
26+
})
27+
})
28+
29+
afterEach(() => {
30+
// @ts-expect-error mocked
31+
window.IntersectionObserver.mockReset()
32+
instanceMap.clear()
33+
observerMap.clear()
34+
})
35+
36+
export function intersect(element: Element, isIntersecting: boolean) {
37+
const cb = observerMap.get(element)
38+
if (cb) {
39+
cb([
40+
{
41+
isIntersecting,
42+
target: element,
43+
intersectionRatio: isIntersecting ? 1 : -1,
44+
},
45+
])
46+
}
47+
}
48+
49+
export function getObserverOf(element: Element): IntersectionObserver {
50+
return instanceMap.get(element)
51+
}

0 commit comments

Comments
 (0)