Skip to content

Commit

Permalink
fix: <MotionGroup> not applying motion to child nodes in v-for (#200)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
BobbieGoede authored Jun 18, 2024
1 parent 6837d52 commit 3a6c840
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 16 deletions.
1 change: 1 addition & 0 deletions src/components/Motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { variantToStyle } from '../utils/transform'
import { MotionComponentProps, setupMotionComponent } from '../utils/component'

export default defineComponent({
name: 'Motion',
props: {
...MotionComponentProps,
is: {
Expand Down
25 changes: 23 additions & 2 deletions src/components/MotionGroup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { PropType, VNode } from 'vue'
import type { Component } from '@nuxt/schema'

import { defineComponent, h, useSlots } from 'vue'
import { Fragment, defineComponent, h, useSlots } from 'vue'
import { variantToStyle } from '../utils/transform'
import { MotionComponentProps, setupMotionComponent } from '../utils/component'

export default defineComponent({
name: 'MotionGroup',
props: {
...MotionComponentProps,
is: {
Expand All @@ -24,7 +25,27 @@ export default defineComponent({

// Set node style on slots and register to `instances` on mount
for (let i = 0; i < nodes.length; i++) {
setNodeInstance(nodes[i], i, style)
const n = nodes[i]

// Recursively assign fragment child nodes
if (n.type === Fragment && Array.isArray(n.children)) {
n.children.forEach(function setChildInstance(child, index) {
if (child == null)
return

if (Array.isArray(child)) {
setChildInstance(child, index)
return
}

if (typeof child === 'object') {
setNodeInstance(child, index, style)
}
})
}
else {
setNodeInstance(n, i, style)
}
}

// Wrap child nodes in component if `props.is` is passed
Expand Down
90 changes: 77 additions & 13 deletions src/utils/component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { type ExtractPropTypes, type PropType, type VNode, computed, nextTick, onUpdated, reactive } from 'vue'
import {
type ExtractPropTypes,
type PropType,
type VNode,
computed,
nextTick,
onUpdated,
reactive,
} from 'vue'
import type { LooseRequired } from '@vue/shared'
import defu from 'defu'
import * as presets from '../presets'
import type { MotionInstance } from '../types/instance'
import type { MotionVariants, StyleProperties, Variant } from '../types/variants'
import type {
MotionVariants,
StyleProperties,
Variant,
} from '../types/variants'
import { useMotion } from '../useMotion'

/**
Expand Down Expand Up @@ -78,15 +90,44 @@ export const MotionComponentProps = {
},
}

function isObject(val: unknown): val is Record<any, any> {
return Object.prototype.toString.call(val) === '[object Object]'
}

/**
* Deep clone object/array
*/
function clone<T>(v: T): any {
if (Array.isArray(v)) {
return v.map(clone)
}

if (isObject(v)) {
const res: any = {}
for (const key in v) {
res[key] = clone(v[key as keyof typeof v])
}
return res
}

return v
}

/**
* Shared logic for <Motion> and <MotionGroup>
*/
export function setupMotionComponent(props: LooseRequired<ExtractPropTypes<typeof MotionComponentProps>>) {
export function setupMotionComponent(
props: LooseRequired<ExtractPropTypes<typeof MotionComponentProps>>,
) {
// Motion instance map
const instances = reactive<{ [key: number]: MotionInstance<string, MotionVariants<string>> }>({})
const instances = reactive<{
[key: number]: MotionInstance<string, MotionVariants<string>>
}>({})

// Preset variant or empty object if none is provided
const preset = computed(() => (props.preset ? structuredClone(presets[props.preset]) : {}))
const preset = computed(() =>
props.preset ? structuredClone(presets[props.preset]) : {},
)

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

// Merged motion configuration using `props.preset`, inline prop variants (`:initial` ...), and `props.variants`
const motionConfig = computed(() => {
const config = defu({}, propsConfig.value, preset.value, props.variants || {})

// Applies transition shorthand helpers to passed config
function applyTransitionHelpers(
config: typeof propsConfig.value,
values: Partial<Pick<typeof props, 'delay' | 'duration'>>,
) {
for (const transitionKey of ['delay', 'duration'] as const) {
if (!props[transitionKey])
if (values[transitionKey] == null)
continue

const transitionValueParsed = Number.parseInt(props[transitionKey] as string)
const transitionValueParsed = Number.parseInt(
values[transitionKey] as string,
)

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

return config
}

// Merged motion configuration using `props.preset`, inline prop variants (`:initial` ...), and `props.variants`
const motionConfig = computed(() => {
const config = defu(
{},
propsConfig.value,
preset.value,
props.variants || {},
)

return applyTransitionHelpers({ ...config }, props)
})

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

// Apply transition helpers, this may differ if `node` is a child node
const elementMotionConfig = applyTransitionHelpers(
clone(motionConfig.value),
node.props as Partial<Pick<typeof props, 'delay' | 'duration'>>,
)

// Track motion instance locally using `instances`
node.props.onVnodeMounted = ({ el }) => {
instances[index] = useMotion<string, MotionVariants<string>>(el as any, motionConfig.value)
instances[index] = useMotion<string, MotionVariants<string>>(
el as any,
elementMotionConfig,
)
}

return node
Expand Down
38 changes: 37 additions & 1 deletion tests/components.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { config, mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { h, nextTick } from 'vue'
import { MotionPlugin } from '../src'
import MotionGroup from '../src/components/MotionGroup'
import { intersect } from './utils/intersectionObserver'
import { getTestComponent, useCompletionFn, waitForMockCalls } from './utils'

Expand Down Expand Up @@ -134,3 +135,38 @@ describe.each([
expect(el.style.transform).toEqual('scale(1) translateZ(0px)')
})
})

describe('`<MotionGroup>` component', async () => {
it('child node can overwrite helpers', async () => {
const wrapper = mount({
render: () =>
h(
MotionGroup,
{
initial: { opacity: 0 },
enter: {
opacity: 0.5,
transition: { ease: 'linear', delay: 100000 },
},
},
[
h('div', { id: 1, key: 1, delay: 0 }),
h('div', { id: 2, key: 2 }),
h('div', { id: 3, key: 3 }),
],
),
})

await new Promise(resolve => setTimeout(resolve, 100))

// First div should have finished `enter` variant
expect(
(wrapper.find('div#1').element as HTMLDivElement).style?.opacity,
).toEqual('0.5')

// Second div should not have started yet
expect(
(wrapper.find('div#2').element as HTMLDivElement).style?.opacity,
).toEqual('0')
})
})

0 comments on commit 3a6c840

Please sign in to comment.