Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/motion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@
"vue": ">=3.0.0"
},
"dependencies": {
"framer-motion": "12.22.0",
"framer-motion": "12.23.12",
"hey-listen": "^1.0.8",
"motion-dom": "12.22.0"
"motion-dom": "12.23.12"
}
}
61 changes: 60 additions & 1 deletion packages/motion/src/components/__tests__/variant.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { Motion } from '@/components'
import { motionValue, stagger } from 'framer-motion/dom'
import { defineComponent, h, onMounted } from 'vue'
import { defineComponent, h, nextTick, onMounted } from 'vue'

describe('animate prop as variant', () => {
it('when: beforeChildren works correctly', async () => {
Expand Down Expand Up @@ -419,4 +419,63 @@ describe('animate prop as variant', () => {
// First stagger should start from first element
expect(firstOrder).toEqual([1, 2, 3, 4])
})
it('staggerChildren is calculated correctly for new children', async () => {
const Component = defineComponent({
props: {
items: {
type: Array as () => string[],
required: true,
},
},
setup(props) {
return () => (
<Motion
animate="enter"
variants={{
enter: { transition: { delayChildren: stagger(0.1) } },
}}
>
{props.items.map(item => (
<Motion
key={item}
id={item}
class="item"
variants={{ enter: { opacity: 1 } }}
initial={{ opacity: 0 }}
/>
))}
</Motion>
)
},
})

const wrapper = mount(Component, {
props: {
items: ['1', '2'],
},
})

await nextTick()
await nextTick()
await nextTick()
await nextTick()

await wrapper.setProps({
items: ['1', '2', '3', '4', '5'],
})

// Wait for animations to complete
await new Promise(resolve => setTimeout(resolve, 1000))

const elements = document.querySelectorAll('.item')

// Check that none of the opacities are the same
const opacities = Array.from(elements).map(el =>
parseFloat(window.getComputedStyle(el).opacity),
)

// All opacities should be unique
const uniqueOpacities = new Set(opacities)
expect(uniqueOpacities.size).toBe(opacities.length)
})
})
24 changes: 20 additions & 4 deletions packages/motion/src/features/animation/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { VisualElement } from 'framer-motion'
import { animate, noop } from 'framer-motion/dom'
import { createVisualElement } from '@/state/create-visual-element'
import { prefersReducedMotion } from 'framer-motion/dist/es/utils/reduced-motion/state.mjs'
import { calcChildStagger } from '@/features/animation/calc-child-stagger'

const STATE_TYPES = ['initial', 'animate', 'whileInView', 'whileHover', 'whilePress', 'whileDrag', 'whileFocus', 'exit'] as const
export type StateType = typeof STATE_TYPES[number]
Expand Down Expand Up @@ -43,7 +44,7 @@ export class AnimationFeature extends Feature {
},
reducedMotionConfig: this.state.options.motionConfig.reducedMotion,
})

this.state.visualElement.parent?.addChild(this.state.visualElement)
this.state.animateUpdates = this.animateUpdates
if (this.state.isMounted())
this.state.startAnimation()
Expand All @@ -61,7 +62,6 @@ export class AnimationFeature extends Feature {
directAnimate,
directTransition,
controlDelay = 0,
isFallback,
isExit,
} = {}) => {
// check if the user has reduced motion
Expand All @@ -77,9 +77,11 @@ export class AnimationFeature extends Feature {
directAnimate,
directTransition,
})
// The final transition to be applied to the state
this.state.finalTransition = animationOptions

const factories = this.createAnimationFactories(prevTarget, animationOptions, controlDelay)
const { getChildAnimations } = this.setupChildAnimations(animationOptions, this.state.activeStates, isFallback)
const { getChildAnimations } = this.setupChildAnimations(animationOptions, this.state.activeStates)
return this.executeAnimations({
factories,
getChildAnimations,
Expand Down Expand Up @@ -145,7 +147,6 @@ export class AnimationFeature extends Feature {
setupChildAnimations(
transition: $Transition | undefined,
controlActiveState: Partial<Record<string, boolean>> | undefined,
isFallback: boolean,
) {
const visualElement = this.state.visualElement
if (!visualElement.variantChildren?.size || !controlActiveState)
Expand Down Expand Up @@ -262,6 +263,21 @@ export class AnimationFeature extends Feature {
// Add state reference to visual element
(this.state.visualElement as any).state = this.state
this.updateAnimationControlsSubscription()

const visualElement = this.state.visualElement
const parentVisualElement = visualElement.parent
visualElement.enteringChildren = undefined
/**
* when current element is new entering child and it's controlled by parent,
* animate it by delayChildren
*/
if (this.state.parent?.isMounted() && !visualElement.isControllingVariants && parentVisualElement?.enteringChildren?.has(visualElement)) {
const { delayChildren } = this.state.parent.finalTransition || {};
(this.animateUpdates({
controlActiveState: this.state.parent.activeStates,
controlDelay: calcChildStagger(parentVisualElement.enteringChildren, visualElement, delayChildren),
}) as Function) ()
}
}

update() {
Expand Down
27 changes: 27 additions & 0 deletions packages/motion/src/features/animation/calc-child-stagger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { VisualElement } from 'framer-motion'
import type { DynamicOption } from 'motion-dom'

export function calcChildStagger(
children: Set<VisualElement>,
child: VisualElement,
delayChildren?: number | DynamicOption<number>,
staggerChildren: number = 0,
staggerDirection: number = 1,
): number {
const sortedChildren = Array.from(children).sort((a, b) => a.sortNodePosition(b))
const index = sortedChildren.indexOf(child)
const numChildren = children.size
const maxStaggerDuration = (numChildren - 1) * staggerChildren
const delayIsFunction = typeof delayChildren === 'function'
/**
* parent may not update, so we need to clear the enteringChildren when the child is the last one
*/
if (index === sortedChildren.length - 1) {
child.parent.enteringChildren = undefined
}
return delayIsFunction
? delayChildren(index, numChildren)
: staggerDirection === 1
? index * staggerChildren
: maxStaggerDuration - index * staggerChildren
}
1 change: 0 additions & 1 deletion packages/motion/src/features/animation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export interface AnimateUpdatesOptions {
controlDelay?: number
directAnimate?: Options['animate']
directTransition?: Options['transition']
isFallback?: boolean
isExit?: boolean
}

Expand Down
4 changes: 0 additions & 4 deletions packages/motion/src/features/dom-animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,4 @@ export const domAnimation = [
HoverGesture,
InViewGesture,
FocusGesture,
// ProjectionFeature,
// DragGesture,
// LayoutFeature,
// PanGesture,
] as unknown as Feature[]
14 changes: 5 additions & 9 deletions packages/motion/src/state/motion-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MotionStateContext, Options } from '@/types'
import type { $Transition, MotionStateContext, Options } from '@/types'
import { invariant } from 'hey-listen'
import type { DOMKeyframesDefinition, VisualElement } from 'framer-motion'
import { cancelFrame, frame, noop } from 'framer-motion/dom'
Expand Down Expand Up @@ -53,14 +53,15 @@ export class MotionState {
*/
public currentProcess: ReturnType<typeof frame.render> | null = null

// Depth in component tree for lifecycle ordering
public depth: number

// Base animation target values
public baseTarget: DOMKeyframesDefinition

// Current animation target values
public target: DOMKeyframesDefinition
/**
* The final transition to be applied to the state
*/
public finalTransition: $Transition
private featureManager: FeatureManager

// Visual element instance from Framer Motion
Expand All @@ -72,8 +73,6 @@ export class MotionState {
this.parent = parent
// Add to parent's children set for lifecycle management
parent?.children?.add(this)
// Calculate depth in component tree
this.depth = parent?.depth + 1 || 0

// Initialize with either initial or animate variant
const initial = (options.initial === undefined && options.variants) ? this.context.initial : options.initial
Expand Down Expand Up @@ -173,8 +172,6 @@ export class MotionState {
this.featureManager.beforeUnmount()
}

// Unmount motion state and optionally unmount children
// Handles unmounting in the correct order based on component tree
unmount(unMountChildren = false) {
/**
* Unlike React, within the same update cycle, the execution order of unmount and mount depends on the component's order in the component tree.
Expand Down Expand Up @@ -228,7 +225,6 @@ export class MotionState {
})
if (isAnimate) {
this.animateUpdates({
isFallback: !isActive && name !== 'exit' && this.visualElement.isControllingVariants,
isExit: name === 'exit' && this.activeStates.exit,
})
}
Expand Down
4 changes: 2 additions & 2 deletions packages/motion/src/utils/use-in-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function useInView(
const isInView = ref(false)

watchEffect((onCleanup) => {
const realOptions = unref(options) || {}
const realOptions = (unref(options) || {}) as UseInViewOptions
const { once } = realOptions
const el = unrefElement(domRef)
if (!el || (once && isInView.value)) {
Expand All @@ -31,7 +31,7 @@ export function useInView(
}
const cleanup = inView(el, onEnter, {
...realOptions,
root: unref(realOptions.root),
root: unref(realOptions.root) as Element | Document,
})
onCleanup(() => {
cleanup()
Expand Down
Loading