Skip to content

Commit 3a6c840

Browse files
authored
fix: <MotionGroup> not applying motion to child nodes in v-for (#200)
* fix: `<MotionGroup>` not applying motion to child nodes in `v-for` * test: add tests for `<MotionGroup>` child nodes helper * fix: clone config object with utility function
1 parent 6837d52 commit 3a6c840

File tree

4 files changed

+138
-16
lines changed

4 files changed

+138
-16
lines changed

src/components/Motion.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { variantToStyle } from '../utils/transform'
66
import { MotionComponentProps, setupMotionComponent } from '../utils/component'
77

88
export default defineComponent({
9+
name: 'Motion',
910
props: {
1011
...MotionComponentProps,
1112
is: {

src/components/MotionGroup.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { PropType, VNode } from 'vue'
22
import type { Component } from '@nuxt/schema'
33

4-
import { defineComponent, h, useSlots } from 'vue'
4+
import { Fragment, defineComponent, h, useSlots } from 'vue'
55
import { variantToStyle } from '../utils/transform'
66
import { MotionComponentProps, setupMotionComponent } from '../utils/component'
77

88
export default defineComponent({
9+
name: 'MotionGroup',
910
props: {
1011
...MotionComponentProps,
1112
is: {
@@ -24,7 +25,27 @@ export default defineComponent({
2425

2526
// Set node style on slots and register to `instances` on mount
2627
for (let i = 0; i < nodes.length; i++) {
27-
setNodeInstance(nodes[i], i, style)
28+
const n = nodes[i]
29+
30+
// Recursively assign fragment child nodes
31+
if (n.type === Fragment && Array.isArray(n.children)) {
32+
n.children.forEach(function setChildInstance(child, index) {
33+
if (child == null)
34+
return
35+
36+
if (Array.isArray(child)) {
37+
setChildInstance(child, index)
38+
return
39+
}
40+
41+
if (typeof child === 'object') {
42+
setNodeInstance(child, index, style)
43+
}
44+
})
45+
}
46+
else {
47+
setNodeInstance(n, i, style)
48+
}
2849
}
2950

3051
// Wrap child nodes in component if `props.is` is passed

src/utils/component.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
import { type ExtractPropTypes, type PropType, type VNode, computed, nextTick, onUpdated, reactive } from 'vue'
1+
import {
2+
type ExtractPropTypes,
3+
type PropType,
4+
type VNode,
5+
computed,
6+
nextTick,
7+
onUpdated,
8+
reactive,
9+
} from 'vue'
210
import type { LooseRequired } from '@vue/shared'
311
import defu from 'defu'
412
import * as presets from '../presets'
513
import type { MotionInstance } from '../types/instance'
6-
import type { MotionVariants, StyleProperties, Variant } from '../types/variants'
14+
import type {
15+
MotionVariants,
16+
StyleProperties,
17+
Variant,
18+
} from '../types/variants'
719
import { useMotion } from '../useMotion'
820

921
/**
@@ -78,15 +90,44 @@ export const MotionComponentProps = {
7890
},
7991
}
8092

93+
function isObject(val: unknown): val is Record<any, any> {
94+
return Object.prototype.toString.call(val) === '[object Object]'
95+
}
96+
97+
/**
98+
* Deep clone object/array
99+
*/
100+
function clone<T>(v: T): any {
101+
if (Array.isArray(v)) {
102+
return v.map(clone)
103+
}
104+
105+
if (isObject(v)) {
106+
const res: any = {}
107+
for (const key in v) {
108+
res[key] = clone(v[key as keyof typeof v])
109+
}
110+
return res
111+
}
112+
113+
return v
114+
}
115+
81116
/**
82117
* Shared logic for <Motion> and <MotionGroup>
83118
*/
84-
export function setupMotionComponent(props: LooseRequired<ExtractPropTypes<typeof MotionComponentProps>>) {
119+
export function setupMotionComponent(
120+
props: LooseRequired<ExtractPropTypes<typeof MotionComponentProps>>,
121+
) {
85122
// Motion instance map
86-
const instances = reactive<{ [key: number]: MotionInstance<string, MotionVariants<string>> }>({})
123+
const instances = reactive<{
124+
[key: number]: MotionInstance<string, MotionVariants<string>>
125+
}>({})
87126

88127
// Preset variant or empty object if none is provided
89-
const preset = computed(() => (props.preset ? structuredClone(presets[props.preset]) : {}))
128+
const preset = computed(() =>
129+
props.preset ? structuredClone(presets[props.preset]) : {},
130+
)
90131

91132
// Motion configuration using inline prop variants (`:initial` ...)
92133
const propsConfig = computed(() => ({
@@ -100,17 +141,19 @@ export function setupMotionComponent(props: LooseRequired<ExtractPropTypes<typeo
100141
focused: props.focused,
101142
}))
102143

103-
// Merged motion configuration using `props.preset`, inline prop variants (`:initial` ...), and `props.variants`
104-
const motionConfig = computed(() => {
105-
const config = defu({}, propsConfig.value, preset.value, props.variants || {})
106-
144+
// Applies transition shorthand helpers to passed config
145+
function applyTransitionHelpers(
146+
config: typeof propsConfig.value,
147+
values: Partial<Pick<typeof props, 'delay' | 'duration'>>,
148+
) {
107149
for (const transitionKey of ['delay', 'duration'] as const) {
108-
if (!props[transitionKey])
150+
if (values[transitionKey] == null)
109151
continue
110152

111-
const transitionValueParsed = Number.parseInt(props[transitionKey] as string)
153+
const transitionValueParsed = Number.parseInt(
154+
values[transitionKey] as string,
155+
)
112156

113-
// TODO: extract to utility function
114157
// Apply transition property to existing variants where applicable
115158
for (const variantKey of ['enter', 'visible', 'visibleOnce'] as const) {
116159
const variantConfig = config[variantKey]
@@ -125,6 +168,18 @@ export function setupMotionComponent(props: LooseRequired<ExtractPropTypes<typeo
125168
}
126169

127170
return config
171+
}
172+
173+
// Merged motion configuration using `props.preset`, inline prop variants (`:initial` ...), and `props.variants`
174+
const motionConfig = computed(() => {
175+
const config = defu(
176+
{},
177+
propsConfig.value,
178+
preset.value,
179+
props.variants || {},
180+
)
181+
182+
return applyTransitionHelpers({ ...config }, props)
128183
})
129184

130185
// Replay animations on component update Vue
@@ -159,9 +214,18 @@ export function setupMotionComponent(props: LooseRequired<ExtractPropTypes<typeo
159214
// Merge node style with variant style
160215
node.props.style = { ...node.props.style, ...style }
161216

217+
// Apply transition helpers, this may differ if `node` is a child node
218+
const elementMotionConfig = applyTransitionHelpers(
219+
clone(motionConfig.value),
220+
node.props as Partial<Pick<typeof props, 'delay' | 'duration'>>,
221+
)
222+
162223
// Track motion instance locally using `instances`
163224
node.props.onVnodeMounted = ({ el }) => {
164-
instances[index] = useMotion<string, MotionVariants<string>>(el as any, motionConfig.value)
225+
instances[index] = useMotion<string, MotionVariants<string>>(
226+
el as any,
227+
elementMotionConfig,
228+
)
165229
}
166230

167231
return node

tests/components.spec.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { config, mount } from '@vue/test-utils'
22
import { describe, expect, it } from 'vitest'
3-
import { nextTick } from 'vue'
3+
import { h, nextTick } from 'vue'
44
import { MotionPlugin } from '../src'
5+
import MotionGroup from '../src/components/MotionGroup'
56
import { intersect } from './utils/intersectionObserver'
67
import { getTestComponent, useCompletionFn, waitForMockCalls } from './utils'
78

@@ -134,3 +135,38 @@ describe.each([
134135
expect(el.style.transform).toEqual('scale(1) translateZ(0px)')
135136
})
136137
})
138+
139+
describe('`<MotionGroup>` component', async () => {
140+
it('child node can overwrite helpers', async () => {
141+
const wrapper = mount({
142+
render: () =>
143+
h(
144+
MotionGroup,
145+
{
146+
initial: { opacity: 0 },
147+
enter: {
148+
opacity: 0.5,
149+
transition: { ease: 'linear', delay: 100000 },
150+
},
151+
},
152+
[
153+
h('div', { id: 1, key: 1, delay: 0 }),
154+
h('div', { id: 2, key: 2 }),
155+
h('div', { id: 3, key: 3 }),
156+
],
157+
),
158+
})
159+
160+
await new Promise(resolve => setTimeout(resolve, 100))
161+
162+
// First div should have finished `enter` variant
163+
expect(
164+
(wrapper.find('div#1').element as HTMLDivElement).style?.opacity,
165+
).toEqual('0.5')
166+
167+
// Second div should not have started yet
168+
expect(
169+
(wrapper.find('div#2').element as HTMLDivElement).style?.opacity,
170+
).toEqual('0')
171+
})
172+
})

0 commit comments

Comments
 (0)