@octane-xplat/motion 0.7.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +31 -2
  2. package/UPSTREAM.md +295 -27
  3. package/dist/native/index.js +980 -112
  4. package/dist/native/octane-client-build.json +1 -1
  5. package/dist/web/index.js +1104 -166
  6. package/dist/web/octane-client-build.json +1 -1
  7. package/package.json +25 -16
  8. package/src/clock.ts +13 -1
  9. package/src/components.mobile.test.tsrx +58 -1
  10. package/src/components.tsrx +185 -39
  11. package/src/components.web.test.tsrx +236 -3
  12. package/src/controller.ts +122 -10
  13. package/src/delegation.test.ts +269 -0
  14. package/src/delegation.ts +29 -0
  15. package/src/delegation.web.test.ts +270 -0
  16. package/src/drag.mobile.test.ts +81 -0
  17. package/src/drag.test.ts +167 -0
  18. package/src/drag.ts +157 -0
  19. package/src/driver.ts +249 -0
  20. package/src/driver.web.ts +180 -0
  21. package/src/engine.test.ts +99 -0
  22. package/src/engine.ts +140 -18
  23. package/src/hooks.tsrx +53 -2
  24. package/src/host-types.ts +44 -1
  25. package/src/host.ts +39 -2
  26. package/src/host.web.ts +51 -10
  27. package/src/index.ts +18 -4
  28. package/src/index.web.ts +18 -4
  29. package/src/pan.ts +92 -0
  30. package/src/pan.web.ts +116 -0
  31. package/src/presence-input.ts +11 -4
  32. package/src/presence-input.web.ts +13 -4
  33. package/src/presence-state.ts +27 -7
  34. package/src/presence.tsrx +6 -7
  35. package/src/reduced-motion.ts +23 -4
  36. package/src/types.ts +100 -11
  37. package/src/value.ts +18 -8
  38. package/src/variants.test.ts +197 -0
  39. package/src/variants.ts +226 -0
  40. package/types/engine.d.ts +8 -2
  41. package/types/index.d.ts +34 -6
  42. package/types/types.d.ts +94 -11
  43. package/types/value.d.ts +7 -4
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "buildId": "a2aeed68-461b-4f57-8879-b03abd73845d",
3
+ "buildId": "560fa479-09ac-4446-bebb-da2e278d56b6",
4
4
  "mode": "production",
5
5
  "capabilities": {
6
6
  "independentHydration": false
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
2
  "name": "@octane-xplat/motion",
3
- "version": "0.7.3",
3
+ "version": "0.10.0",
4
4
  "description": "Declarative motion and retained presence for Octane on web, iOS and Android",
5
5
  "keywords": [
6
- "octane",
7
- "xplat",
8
- "cross-platform",
6
+ "android",
9
7
  "animation",
8
+ "cross-platform",
9
+ "ios",
10
10
  "motion",
11
- "transitions",
12
- "spring",
13
- "presence",
14
11
  "nativescript",
15
- "ios",
16
- "android",
17
- "web"
12
+ "octane",
13
+ "presence",
14
+ "spring",
15
+ "transitions",
16
+ "web",
17
+ "xplat"
18
18
  ],
19
- "license": "MIT",
20
19
  "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/motion",
20
+ "license": "MIT",
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "git+https://github.com/octane-xplat/octane-xplat.git",
23
+ "url": "https://github.com/octane-xplat/octane-xplat",
24
24
  "directory": "packages/motion"
25
25
  },
26
26
  "files": [
@@ -43,20 +43,23 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@octane-xplat/ui": "0.7.3",
46
+ "@octane-xplat/ui": "0.10.0",
47
47
  "motion-dom": "12.42.2"
48
48
  },
49
49
  "devDependencies": {
50
- "@nativescript-community/octane": "0.2.1",
50
+ "@nativescript-community/gesturehandler": "2.0.45",
51
+ "@nativescript-community/octane": "0.2.4",
51
52
  "@nativescript/core": "9.1.2",
52
53
  "@octanejs/vite-plugin": "0.1.61",
53
54
  "jsdom": "30.1.1",
54
55
  "octane": "0.6.3",
56
+ "tsrx-typegen": "0.2.2",
55
57
  "typescript": "5.9.3",
56
58
  "vite": "8.3.0",
57
59
  "vitest": "5.0.1"
58
60
  },
59
61
  "peerDependencies": {
62
+ "@nativescript-community/gesturehandler": "2.0.45",
60
63
  "@nativescript-community/octane": ">=0.2.1 <1",
61
64
  "@nativescript/core": ">=9.1.0 <10",
62
65
  "octane": ">=0.1.51 <1"
@@ -67,10 +70,16 @@
67
70
  },
68
71
  "@nativescript/core": {
69
72
  "optional": true
73
+ },
74
+ "@nativescript-community/gesturehandler": {
75
+ "optional": true
70
76
  }
71
77
  },
72
78
  "scripts": {
73
- "build": "vite build && vite build --mode native && tsc -p tsconfig.types.json",
74
- "test": "vitest run"
79
+ "build": "vite build && vite build --mode native && pnpm typegen",
80
+ "test": "vitest run",
81
+ "typegen": "tsc -p tsconfig.types.json",
82
+ "test:packed": "node tests/packed-consumer.mjs",
83
+ "pack:check": "tsrx-typegen --pack-check"
75
84
  }
76
85
  }
package/src/clock.ts CHANGED
@@ -1,7 +1,19 @@
1
1
  import { requestAnimationFrame, cancelAnimationFrame } from '@nativescript/core/animation-frame'
2
+ import { Application } from '@nativescript/core'
2
3
  import type { Clock } from './clock-types'
4
+
5
+ const platform = globalThis as any
6
+
7
+ const now: () => number = Application.ios
8
+ ? platform.CACurrentMediaTime
9
+ ? () => platform.CACurrentMediaTime() * 1000
10
+ : (platform.__time ?? Date.now)
11
+ : platform.java?.lang?.System
12
+ ? () => platform.java.lang.System.nanoTime() / 1e6
13
+ : Date.now
14
+
3
15
  export const clock: Clock = {
4
- now: () => Date.now(),
16
+ now,
5
17
  request: requestAnimationFrame,
6
18
  cancel: cancelAnimationFrame,
7
19
  }
@@ -38,7 +38,6 @@ describe('native compiled hosts', () => {
38
38
  })
39
39
  })
40
40
 
41
- import { vi } from 'vitest'
42
41
  import { useEffect } from 'octane'
43
42
  import { Presence } from './index'
44
43
  let nativeCleanups = 0
@@ -96,3 +95,61 @@ it('retargets a retained native host on a signal write', () => {
96
95
  expect(node.translateX).toBe(75)
97
96
  root.unmount()
98
97
  })
98
+
99
+ import { handlers, GestureHandlerStateEvent, GestureHandlerTouchEvent, GestureState } from '../test/gesturehandler'
100
+
101
+ function NativeDrag() @{
102
+ <MotionConfig reducedMotion="always">
103
+ <motion.View id="native-drag" drag="x" dragConstraints={{ left: -40, right: 40 }} dragElastic={false} />
104
+ </MotionConfig>
105
+ }
106
+
107
+ it('drives the retained native host from its handler and snaps reduced settlement', () => {
108
+ const container = createObjectContainer('nativescript')
109
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
110
+ root.render(NativeDrag, {})
111
+ const node: any = container.children[0]
112
+ const handler = handlers.findLast((h) => h.node === node)
113
+ expect(handler).toBeDefined()
114
+ handler.emit(GestureHandlerStateEvent, { state: GestureState.ACTIVE, extraData: { translationX: 10 } })
115
+ handler.emit(GestureHandlerTouchEvent, { state: GestureState.ACTIVE, extraData: { translationX: 20 } })
116
+ expect(node.translateX).toBe(20)
117
+ handler.emit(GestureHandlerStateEvent, { state: GestureState.END, extraData: { translationX: 20, velocityX: 250 } })
118
+ expect(node.translateX).toBe(40)
119
+ root.unmount()
120
+ expect(handler.node).toBeUndefined()
121
+ })
122
+
123
+ function NativeVariants(props: any) @{
124
+ <motion.View animate={props.label} variants={{ show: { transition: { duration: 0, when: 'beforeChildren' } }, hide: { transition: { duration: 0 } } }}>
125
+ <motion.View custom={props.custom ?? 70} variants={{ show: (custom: number) => ({ x: custom, transition: { duration: 0 } }), hide: { x: 0, transition: { duration: 0 } } }} />
126
+ </motion.View>
127
+ }
128
+
129
+ it('propagates variant labels to retained native children', async () => {
130
+ const container = createObjectContainer('nativescript')
131
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
132
+ root.render(NativeVariants, { label: 'show' })
133
+ await new Promise((resolve) => setTimeout(resolve, 0))
134
+ const child: any = container.children[0].children[0]
135
+ expect(child.translateX).toBe(70)
136
+ flushUniversalSync(() => root.render(NativeVariants, { label: 'hide' }))
137
+ await new Promise((resolve) => setTimeout(resolve, 0))
138
+ expect(container.children[0].children[0]).toBe(child)
139
+ expect(child.translateX).toBe(0)
140
+ root.unmount()
141
+ })
142
+
143
+ it('retargets native inherited custom resolvers with an unchanged parent label', async () => {
144
+ const container = createObjectContainer('nativescript')
145
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
146
+ root.render(NativeVariants, { label: 'show', custom: 30 })
147
+ await new Promise((resolve) => setTimeout(resolve, 0))
148
+ const child: any = container.children[0].children[0]
149
+ expect(child.translateX).toBe(30)
150
+ flushUniversalSync(() => root.render(NativeVariants, { label: 'show', custom: 50 }))
151
+ await new Promise((resolve) => setTimeout(resolve, 0))
152
+ expect(container.children[0].children[0]).toBe(child)
153
+ expect(child.translateX).toBe(50)
154
+ root.unmount()
155
+ })
@@ -1,42 +1,82 @@
1
- import { useRef, useLayoutEffect, useContext } from 'octane'
2
- import { View, Row, Pressable } from '@octane-xplat/ui'
1
+ import { createContext, useRef, useLayoutEffect, useContext } from 'octane'
2
+ import { View, HStack, Pressable } from '@octane-xplat/ui'
3
3
  import { ConfigContext, useReducedMotion } from './config.tsrx'
4
4
  import { PresenceContext } from './presence.tsrx'
5
5
  import type { PresenceMember } from './presence-state'
6
+ import { attachDrag, validateDrag } from './drag'
6
7
  import { clock } from './clock'
7
8
  import { attachHost } from './host'
8
- import type { HostAdapter } from './host-types'
9
- import { Controller, keys, defaults, validateTarget } from './controller'
10
- import { validateTransition } from './engine'
9
+ import type { GestureKind, HostAdapter } from './host-types'
10
+ import { Controller, keys, defaults } from './controller'
11
+ import { validateTransitionInput } from './engine'
12
+ import { VariantNode, isLabels, resolveVariant, playbackTransition } from './variants'
11
13
  import { MotionValue } from './value'
12
- import type { MotionKey, MotionProps, MotionViewProps, MotionRowProps, MotionPressableProps, Target } from './types'
14
+ import type { MotionKey, MotionProps, MotionViewProps, MotionRowProps, MotionPressableProps, Target, AnimationDefinition } from './types'
13
15
 
14
- function useMotion(props: MotionProps & { bind?: (host: any) => void }) {
15
- for (const name of ['drag', 'layout', 'layoutId', 'variants', 'whileTap', 'whileHover', 'whileFocus', 'whileInView']) {
16
+ const VariantContext = createContext<VariantNode | null>(null)
17
+
18
+ /** host node → its motion controller; powers `useAnimate` scope lookup. */
19
+ export const controllers = new WeakMap<object, Controller>()
20
+
21
+ const GESTURE_PROPS: Record<GestureKind, { target: 'whileTap' | 'whileFocus'; transition: 'whileTapTransition' | 'whileFocusTransition' }> = {
22
+ press: { target: 'whileTap', transition: 'whileTapTransition' },
23
+ focus: { target: 'whileFocus', transition: 'whileFocusTransition' },
24
+ }
25
+
26
+ function useMotion(props: MotionProps & Pick<MotionViewProps, 'ref'>) {
27
+ for (const name of ['layout', 'layoutId', 'inherit', 'whileHover', 'whileInView', 'whileDrag']) {
16
28
  if (name in props) throw new Error(`motion: ${name} is outside the supported subset`)
17
29
  }
18
30
 
31
+ validateDrag(props)
32
+
33
+ const parentVariant = useContext(VariantContext)
34
+ const inherits = props.animate === undefined && parentVariant !== null
35
+ const initialDefinition = props.initial ?? parentVariant?.initial
36
+ const animateDefinition = props.animate ?? parentVariant?.animate
37
+ const resolve = (definition: AnimationDefinition | false | undefined, source: MotionProps = props) => {
38
+ const resolved = resolveVariant(definition, source.variants, source.custom)
39
+ for (const key of keys) {
40
+ if (resolved.target[key] !== undefined && source.style?.[key] instanceof MotionValue) throw new Error(`motion: ${key} has both an animate target and a MotionValue writer`)
41
+ }
42
+
43
+ return resolved
44
+ }
45
+
46
+ const resolvedAnimate = resolve(animateDefinition)
47
+ const resolvedInitial = resolve(initialDefinition)
48
+ const resolvedExit = resolve(props.exit)
19
49
  const presence = useContext(PresenceContext)
20
50
  if (props.exit && !presence) throw new Error('motion: exit requires a mounted Presence boundary')
21
51
  const config = useContext(ConfigContext)
22
52
  const systemReduced = useReducedMotion()
23
53
  const reduced = config.reducedMotion === 'always' || (config.reducedMotion === 'user' && systemReduced)
24
54
  const transition = props.transition ?? config.transition ?? {}
25
- validateTransition(transition)
26
- validateTarget(props.animate)
27
- validateTarget(props.exit)
28
- if (props.initial !== false) validateTarget(props.initial)
55
+ validateTransitionInput(transition)
56
+ resolve(props.whileTap)
57
+ resolve(props.whileFocus)
58
+ if (props.whileTapTransition) validateTransitionInput(props.whileTapTransition)
59
+ if (props.whileFocusTransition) validateTransitionInput(props.whileFocusTransition)
29
60
  const latest = useRef(props)
30
61
  latest.current = props
31
- const state = useRef<{ controller: Controller; host?: any; adapter?: HostAdapter; initialized: boolean } | null>(null)
32
- if (!state.current) state.current = { controller: new Controller(clock), initialized: false }
62
+ const state = useRef<{
63
+ controller: Controller
64
+ host?: any
65
+ adapter?: HostAdapter
66
+ initialized: boolean
67
+ gestureOff?: () => void
68
+ dragOff?: () => void
69
+ preGesture?: Record<GestureKind, Target | undefined>
70
+ } | null>(null)
71
+
72
+ if (!state.current) state.current = { controller: new Controller(clock), initialized: false, preGesture: { press: undefined, focus: undefined } }
33
73
  const current = state.current
34
74
  const style: Record<string, unknown> = {}
35
75
  const bindings: [MotionKey, number | MotionValue][] = []
36
76
  for (const [key, value] of Object.entries(props.style ?? {})) {
37
77
  if (keys.includes(key as MotionKey)) {
38
78
  if (!(value instanceof MotionValue) && (typeof value !== 'number' || !Number.isFinite(value))) throw new Error(`motion: invalid style ${key}`)
39
- if (value instanceof MotionValue && (props.animate?.[key as MotionKey] !== undefined || props.exit?.[key as MotionKey] !== undefined || (props.initial && props.initial[key as MotionKey] !== undefined))) throw new Error(`motion: ${key} has both an animate target and a MotionValue writer`)
79
+ if (value instanceof MotionValue && (resolvedAnimate.target[key as MotionKey] !== undefined || resolvedExit.target[key as MotionKey] !== undefined || (resolvedInitial.target[key as MotionKey] !== undefined))) throw new Error(`motion: ${key} has both an animate target and a MotionValue writer`)
40
80
  bindings.push([key as MotionKey, value as number | MotionValue])
41
81
  } else {
42
82
  if (value instanceof MotionValue || ['transform', 'translateX', 'translateY', 'animation', 'transition'].includes(key)) throw new Error(`motion: unsupported or conflicting style ${key}`)
@@ -48,11 +88,40 @@ function useMotion(props: MotionProps & { bind?: (host: any) => void }) {
48
88
  const boundKeys = useRef<MotionKey[]>([])
49
89
  const firstBinding = useRef(true)
50
90
  const firstAnimation = useRef(true)
91
+ const inheritedRun = useRef<{ label: string; resolved: string } | null>(null)
51
92
  if (previous.current.length !== bindings.length || previous.current.some(([key, value], i) => key !== bindings[i][0] || value !== bindings[i][1])) previous.current = bindings
52
- const bind = useRef<(host: any) => void>(() => {})
53
- bind.current = (host) => {
93
+ current.controller.onUpdate = (snapshot) => latest.current.onUpdate?.(snapshot)
94
+
95
+ const gesture = (kind: GestureKind, phase: 'start' | 'end') => {
96
+ const shape = GESTURE_PROPS[kind]
97
+ const definition = latest.current[shape.target]
98
+ if (!definition) return
99
+ const { target, transition: override } = resolve(definition, latest.current)
100
+ const gestureTransition = playbackTransition(override ?? latest.current[shape.transition] ?? playback.current.transition)
101
+ if (phase === 'start') {
102
+ // Snapshot the gesture's keys so release returns to where they were.
103
+ const snapshot = current.controller.snapshot()
104
+ const rest: Target = {}
105
+ for (const key of Object.keys(target) as MotionKey[]) rest[key] = snapshot[key]
106
+ current.preGesture![kind] = rest
107
+ void current.controller.animate(target, gestureTransition, playback.current.reduced)
108
+ } else {
109
+ const snap = current.preGesture![kind] ?? {}
110
+ current.preGesture![kind] = undefined
111
+ // The declarative animate prop wins over the pre-gesture snapshot.
112
+ const rest: Target = {}
113
+ for (const key of Object.keys(target) as MotionKey[]) rest[key] = resolve(latest.current.animate ?? parentVariant?.animate, latest.current).target[key] ?? snap[key] ?? defaults[key]
114
+ void current.controller.animate(rest, gestureTransition, playback.current.reduced)
115
+ }
116
+ }
117
+
118
+ const ref = useRef<(host: any) => void>(() => {})
119
+ ref.current = (host) => {
54
120
  if (!host) return
55
121
  if (current.host !== host) {
122
+ current.gestureOff?.()
123
+ current.dragOff?.()
124
+ current.gestureOff = undefined
56
125
  current.adapter?.restore()
57
126
  current.host = host
58
127
  current.adapter = attachHost(host)
@@ -60,43 +129,73 @@ function useMotion(props: MotionProps & { bind?: (host: any) => void }) {
60
129
  const baseline = current.adapter.read()
61
130
  for (const [key, value] of previous.current) baseline[key] = value instanceof MotionValue ? value.get() : value
62
131
  current.controller.seed(baseline)
63
- current.controller.seed(props.initial === false ? props.animate ?? {} : props.initial ?? {})
132
+ current.controller.seed(initialDefinition === false ? resolvedAnimate.target : resolvedInitial.target)
64
133
  current.initialized = true
65
134
  }
66
135
 
67
- current.controller.attach(current.adapter.write)
136
+ current.controller.attach(current.adapter)
137
+ controllers.set(host, current.controller)
138
+ configureDrag()
139
+ if (current.adapter.gesture) {
140
+ const offs = (['press', 'focus'] as const)
141
+ .map((kind) => current.adapter!.gesture!(kind, { start: () => gesture(kind, 'start'), end: () => gesture(kind, 'end') }))
142
+ .filter((off): off is () => void => typeof off === 'function')
143
+
144
+ current.gestureOff = () => offs.forEach((off) => off())
145
+ }
68
146
  }
69
147
 
70
148
  current.controller.flush()
71
- latest.current.bind?.(host)
72
149
  }
73
150
 
74
151
  const playback = useRef({ transition, reduced })
75
152
  playback.current = { transition, reduced }
153
+ const nodeRef = useRef<VariantNode | null>(null)
154
+ if (!nodeRef.current) nodeRef.current = new VariantNode()
155
+ const node = nodeRef.current
156
+ node.initial = initialDefinition === false || isLabels(initialDefinition) ? initialDefinition : undefined
157
+ node.animate = isLabels(animateDefinition) ? animateDefinition : undefined
158
+ node.resolve = (definition) => {
159
+ const resolved = resolve(definition, latest.current)
160
+ return { target: resolved.target, transition: resolved.transition ?? playback.current.transition }
161
+ }
162
+
163
+ node.stopOwn = () => current.controller.stop()
164
+ node.play = async (definition, target, timing) => {
165
+ latest.current.onAnimationStart?.(definition)
166
+ const result = await current.controller.animate(target, timing, playback.current.reduced)
167
+ if (result === 'finished' && current.host) latest.current.onAnimationComplete?.()
168
+ return result
169
+ }
170
+
171
+ useLayoutEffect(() => inherits ? parentVariant!.register(node) : undefined, [inherits, parentVariant])
76
172
  const returning = useRef<Target | null>(null)
77
173
  const member = useRef<PresenceMember | null>(null)
78
174
  if (!member.current) member.current = {
79
175
  exit() {
176
+ node.cancelPending()
80
177
  const snapshot = current.controller.snapshot()
81
178
  returning.current ??= {}
82
- for (const key of Object.keys(latest.current.exit ?? {}) as MotionKey[]) {
179
+ const resolved = resolve(latest.current.exit, latest.current)
180
+ for (const key of Object.keys(resolved.target) as MotionKey[]) {
83
181
  if (!(key in returning.current)) returning.current[key] = snapshot[key]
84
182
  }
85
183
 
86
- return current.controller.animate(latest.current.exit ?? {}, playback.current.transition, playback.current.reduced)
184
+ return current.controller.animate(resolved.target, playbackTransition(resolved.transition ?? playback.current.transition), playback.current.reduced)
87
185
  },
88
186
  enter() {
89
- const target = { ...returning.current, ...latest.current.animate }
187
+ const resolved = resolve(latest.current.animate ?? parentVariant?.animate, latest.current)
188
+ const target = { ...returning.current, ...resolved.target }
90
189
  returning.current = null
91
- void current.controller.animate(target, playback.current.transition, playback.current.reduced).then((result) => {
190
+ void current.controller.animate(target, playbackTransition(resolved.transition ?? playback.current.transition), playback.current.reduced).then((result) => {
92
191
  if (result === 'finished' && current.host && presence?.present) latest.current.onAnimationComplete?.()
93
192
  })
94
193
  },
95
194
  }
96
195
 
97
196
  useLayoutEffect(() => presence?.register(member.current!), [presence])
98
- const stableBind = useRef((host: any) => bind.current(host)).current
99
- useLayoutEffect(() => () => { current.controller.destroy(); current.host = undefined; current.adapter = undefined }, [])
197
+ const hostRef = useRef((host: any) => ref.current(host)).current
198
+ useLayoutEffect(() => () => { node.stop(); current.dragOff?.(); current.gestureOff?.(); current.controller.destroy(); current.host = undefined; current.adapter = undefined }, [])
100
199
  useLayoutEffect(() => {
101
200
  const off: (() => void)[] = []
102
201
  for (const key of boundKeys.current) if (!previous.current.some(([next]) => key === next)) current.controller.set(key, defaults[key])
@@ -110,33 +209,80 @@ function useMotion(props: MotionProps & { bind?: (host: any) => void }) {
110
209
  return () => off.forEach((unsubscribe) => unsubscribe())
111
210
  }, [previous.current])
112
211
 
212
+ const configureDrag = () => {
213
+ current.dragOff?.()
214
+ current.dragOff = undefined
215
+ if (!current.adapter) return
216
+ current.dragOff = attachDrag(current.adapter, current.controller, latest.current, (axis) => {
217
+ const binding = previous.current.find(([key]) => key === axis)?.[1]
218
+ return binding instanceof MotionValue ? binding : current.controller.value(axis)
219
+ }, () => playback.current.reduced, () => latest.current)
220
+ }
221
+
113
222
  useLayoutEffect(() => {
114
- let active = true
115
- const skip = firstAnimation.current && props.initial === false
223
+ configureDrag()
224
+ }, [props.drag, JSON.stringify(props.dragConstraints), props.dragElastic, props.dragMomentum, previous.current])
225
+
226
+ useLayoutEffect(() => {
227
+ const skip = firstAnimation.current && initialDefinition === false
116
228
  firstAnimation.current = false
117
229
  if (presence && !presence.present) presence.restart(member.current!)
118
- else if (props.animate && !skip) void current.controller.animate(props.animate, transition, reduced).then((result) => {
119
- if (active && result === 'finished') latest.current.onAnimationComplete?.()
120
- })
230
+ else if (animateDefinition !== undefined && !skip) {
231
+ if (!inherits) void node.run(animateDefinition)
232
+ else {
233
+ const label = JSON.stringify(animateDefinition)
234
+ const resolved = JSON.stringify({ resolvedAnimate, transition, reduced })
235
+ const previousRun = inheritedRun.current
236
+ if (previousRun?.label === label && previousRun.resolved !== resolved) {
237
+ const parentTiming = parentVariant!.resolve(animateDefinition).transition
238
+ void node.run(animateDefinition, (parentTiming.delayChildren ?? 0) + [...parentVariant!.children].indexOf(node) * (parentTiming.staggerChildren ?? 0))
239
+ }
240
+
241
+ inheritedRun.current = { label, resolved }
242
+ }
243
+ }
121
244
 
122
- return () => { active = false; current.controller.stop() }
123
- }, [JSON.stringify(props.animate), JSON.stringify(props.exit), JSON.stringify(transition), reduced])
245
+ return () => { if (!inherits) node.stop() }
246
+ }, [JSON.stringify(resolvedAnimate), JSON.stringify(resolvedExit), JSON.stringify(animateDefinition), JSON.stringify(transition), reduced, inherits])
124
247
 
125
- const { initial, exit, animate, transition: ignoredTransition, onAnimationComplete, style: ignoredStyle, bind: ignoredBind, ...rest } = props
126
- return { ...rest, style, bind: stableBind }
248
+ const { drag, dragConstraints, dragElastic, dragMomentum, onDragStart, onDrag, onDragEnd, variants, custom, initial, exit, animate, transition: ignoredTransition, whileTap, whileTapTransition, whileFocus, whileFocusTransition, onAnimationStart, onAnimationComplete, onUpdate, style: ignoredStyle, ref: ignoredRef, ...rest } = props
249
+ return { hostProps: { ...rest, style, ref: [hostRef, props.ref] }, node }
250
+ }
251
+
252
+ function MotionLeaf(props: MotionProps & { component?: any; children?: any }) @{
253
+ const Component = props.component
254
+ const { hostProps, node } = useMotion(props)
255
+
256
+ <VariantContext value={node}><Component {...hostProps}>{props.children}</Component></VariantContext>
257
+ }
258
+
259
+ /** Wrap any component that accepts `ref`/`style`/`children` — the portable
260
+ * equivalent of upstream's `motion.<tag>` host factory. Compiled components
261
+ * take (props, slotState, extra) — the factory forwards them into MotionLeaf,
262
+ * whose dynamic <Component> tag renders the wrapped leaf. */
263
+ export function createMotion<P extends Record<string, unknown>>(Component: (props: P) => any) {
264
+ return function MotionCreated(props: P & MotionProps, __s?: any, __extra?: any) {
265
+ return (MotionLeaf as any)({ ...props, component: Component }, __s, __extra)
266
+ }
127
267
  }
128
268
 
129
269
  /** Animated View preserving the shared primitive's layout and accessibility. */
130
270
  export function MotionView(props: MotionViewProps) @{
131
- <View {...useMotion(props)}>{props.children}</View>
271
+ const { hostProps, node } = useMotion(props)
272
+
273
+ <VariantContext value={node}><View {...hostProps}>{props.children}</View></VariantContext>
132
274
  }
133
275
 
134
- /** Animated horizontal Row. */
276
+ /** Animated horizontal stack. */
135
277
  export function MotionRow(props: MotionRowProps) @{
136
- <Row {...useMotion(props)}>{props.children}</Row>
278
+ const { hostProps, node } = useMotion(props)
279
+
280
+ <VariantContext value={node}><HStack {...hostProps}>{props.children}</HStack></VariantContext>
137
281
  }
138
282
 
139
283
  /** Animated Pressable preserving its input callbacks and disabled state. */
140
284
  export function MotionPressable(props: MotionPressableProps) @{
141
- <Pressable {...useMotion(props)}>{props.children}</Pressable>
285
+ const { hostProps, node } = useMotion(props)
286
+
287
+ <VariantContext value={node}><Pressable {...hostProps}>{props.children}</Pressable></VariantContext>
142
288
  }