@octane-xplat/motion 0.0.0 → 0.7.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.
- package/LICENSE.motion +21 -0
- package/README.md +15 -0
- package/UPSTREAM.md +61 -0
- package/dist/native/index.js +1747 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/index.js +1826 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +59 -5
- package/src/clock-types.ts +6 -0
- package/src/clock.ts +7 -0
- package/src/clock.web.ts +6 -0
- package/src/components.mobile.test.tsrx +84 -0
- package/src/components.tsrx +142 -0
- package/src/components.web.test.tsrx +134 -0
- package/src/config.tsrx +29 -0
- package/src/controller.ts +98 -0
- package/src/engine.test.ts +126 -0
- package/src/engine.ts +159 -0
- package/src/hooks.tsrx +56 -0
- package/src/host-types.ts +6 -0
- package/src/host.ts +50 -0
- package/src/host.web.ts +34 -0
- package/src/index.ts +9 -0
- package/src/index.web.ts +9 -0
- package/src/presence-input.ts +26 -0
- package/src/presence-input.web.ts +17 -0
- package/src/presence-state.test.ts +64 -0
- package/src/presence-state.ts +76 -0
- package/src/presence.tsrx +52 -0
- package/src/presence.web.test.tsrx +122 -0
- package/src/reduced-motion.ts +37 -0
- package/src/reduced-motion.web.ts +13 -0
- package/src/types.ts +55 -0
- package/src/value.ts +167 -0
- package/types/clock-types.d.ts +6 -0
- package/types/engine.d.ts +11 -0
- package/types/index.d.ts +53 -0
- package/types/types.d.ts +51 -0
- package/types/value.d.ts +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@octane-xplat/motion",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Declarative motion and retained presence for Octane on web, iOS and Android",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/octane-xplat/octane-xplat.git",
|
|
8
|
+
"directory": "packages/motion"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"types",
|
|
13
|
+
"src",
|
|
14
|
+
"LICENSE.motion",
|
|
15
|
+
"UPSTREAM.md"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./types/index.d.ts",
|
|
21
|
+
"web": "./dist/web/index.js",
|
|
22
|
+
"native": "./dist/native/index.js",
|
|
23
|
+
"default": "./dist/native/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@octane-xplat/ui": "0.7.0",
|
|
31
|
+
"motion-dom": "12.42.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@nativescript-community/octane": "0.2.1",
|
|
35
|
+
"@nativescript/core": "9.1.2",
|
|
36
|
+
"@octanejs/vite-plugin": "0.1.61",
|
|
37
|
+
"jsdom": "30.1.1",
|
|
38
|
+
"octane": "0.6.3",
|
|
39
|
+
"typescript": "5.9.3",
|
|
40
|
+
"vite": "8.3.0",
|
|
41
|
+
"vitest": "5.0.1"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
45
|
+
"@nativescript/core": ">=9.1.0 <10",
|
|
46
|
+
"octane": ">=0.1.51 <1"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@nativescript-community/octane": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@nativescript/core": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "vite build && vite build --mode native && tsc -p tsconfig.types.json",
|
|
58
|
+
"test": "vitest run"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { requestAnimationFrame, cancelAnimationFrame } from '@nativescript/core/animation-frame'
|
|
2
|
+
import type { Clock } from './clock-types'
|
|
3
|
+
export const clock: Clock = {
|
|
4
|
+
now: () => Date.now(),
|
|
5
|
+
request: requestAnimationFrame,
|
|
6
|
+
cancel: cancelAnimationFrame,
|
|
7
|
+
}
|
package/src/clock.web.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** @jsxImportSource @nativescript-community/octane */
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createObjectContainer, createObjectDriver, createUniversalRoot, flushUniversalSync } from 'octane/universal/native'
|
|
4
|
+
import { motion, MotionConfig } from './index'
|
|
5
|
+
|
|
6
|
+
function NativeBox(props: any) @{
|
|
7
|
+
<MotionConfig reducedMotion="always">
|
|
8
|
+
<motion.View id="native" initial={{ x: 10 }} animate={{ x: props.x }} transition={{ duration: 20 }} />
|
|
9
|
+
</MotionConfig>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('native compiled hosts', () => {
|
|
13
|
+
it('updates retained hosts through universal context and props', () => {
|
|
14
|
+
const container = createObjectContainer('nativescript')
|
|
15
|
+
const root = createUniversalRoot(container, createObjectDriver('nativescript'))
|
|
16
|
+
root.render(NativeBox, { x: 50 })
|
|
17
|
+
const node: any = container.children[0]
|
|
18
|
+
expect(node.translateX).toBe(50)
|
|
19
|
+
flushUniversalSync(() => root.render(NativeBox, { x: 80 }))
|
|
20
|
+
expect(container.children[0]).toBe(node)
|
|
21
|
+
expect(node.translateX).toBe(80)
|
|
22
|
+
root.unmount()
|
|
23
|
+
expect(container.children.length).toBe(0)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
import { vi } from 'vitest'
|
|
28
|
+
import { useEffect } from 'octane'
|
|
29
|
+
import { Presence } from './index'
|
|
30
|
+
let nativeCleanups = 0
|
|
31
|
+
function NativeChild() @{
|
|
32
|
+
useEffect(() => () => { nativeCleanups++ }, [])
|
|
33
|
+
|
|
34
|
+
<motion.View id="child" animate={{ x: 0 }} exit={{ x: 100 }} transition={{ duration: 0.1 }} />
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function NativePresence(props: any) @{
|
|
38
|
+
<Presence present={props.present} onExitComplete={props.done}><NativeChild /></Presence>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('native presence lifecycle', () => {
|
|
42
|
+
it('retains and reverses a live subtree, then removes exactly once', async () => {
|
|
43
|
+
vi.useFakeTimers(); nativeCleanups = 0
|
|
44
|
+
const container = createObjectContainer('nativescript')
|
|
45
|
+
const root = createUniversalRoot(container, createObjectDriver('nativescript'))
|
|
46
|
+
const done = vi.fn()
|
|
47
|
+
root.render(NativePresence, { present: true, done })
|
|
48
|
+
const boundary: any = container.children[0]
|
|
49
|
+
const child = boundary.children[0]
|
|
50
|
+
root.render(NativePresence, { present: false, done })
|
|
51
|
+
expect(boundary.isUserInteractionEnabled).toBe(false)
|
|
52
|
+
expect(nativeCleanups).toBe(0)
|
|
53
|
+
await vi.advanceTimersByTimeAsync(32)
|
|
54
|
+
root.render(NativePresence, { present: true, done })
|
|
55
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
56
|
+
flushUniversalSync(() => {})
|
|
57
|
+
expect(container.children[0]).toBe(boundary)
|
|
58
|
+
expect(boundary.children[0]).toBe(child)
|
|
59
|
+
expect(nativeCleanups).toBe(0); expect(done).not.toHaveBeenCalled()
|
|
60
|
+
root.render(NativePresence, { present: false, done })
|
|
61
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
62
|
+
flushUniversalSync(() => {})
|
|
63
|
+
expect(container.children.length).toBe(0)
|
|
64
|
+
expect(nativeCleanups).toBe(1); expect(done).toHaveBeenCalledTimes(1)
|
|
65
|
+
root.unmount(); vi.useRealTimers()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
import { signal$ } from 'octane/signals'
|
|
70
|
+
const destination$ = signal$(0)
|
|
71
|
+
function NativeSignal() @{
|
|
72
|
+
<motion.Pressable onPress={() => destination$.set(75)} animate={{ x: destination$.get() }} transition={{ duration: 0 }} />
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it('retargets a retained native host on a signal write', () => {
|
|
76
|
+
const container = createObjectContainer('nativescript')
|
|
77
|
+
const root = createUniversalRoot(container, createObjectDriver('nativescript'))
|
|
78
|
+
root.render(NativeSignal, {})
|
|
79
|
+
const node: any = container.children[0]
|
|
80
|
+
flushUniversalSync(() => container.dispatchEvent(node, 'tap', {}))
|
|
81
|
+
expect(container.children[0]).toBe(node)
|
|
82
|
+
expect(node.translateX).toBe(75)
|
|
83
|
+
root.unmount()
|
|
84
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useRef, useLayoutEffect, useContext } from 'octane'
|
|
2
|
+
import { View, Row, Pressable } from '@octane-xplat/ui'
|
|
3
|
+
import { ConfigContext, useReducedMotion } from './config.tsrx'
|
|
4
|
+
import { PresenceContext } from './presence.tsrx'
|
|
5
|
+
import type { PresenceMember } from './presence-state'
|
|
6
|
+
import { clock } from './clock'
|
|
7
|
+
import { attachHost } from './host'
|
|
8
|
+
import type { HostAdapter } from './host-types'
|
|
9
|
+
import { Controller, keys, defaults, validateTarget } from './controller'
|
|
10
|
+
import { validateTransition } from './engine'
|
|
11
|
+
import { MotionValue } from './value'
|
|
12
|
+
import type { MotionKey, MotionProps, MotionViewProps, MotionRowProps, MotionPressableProps, Target } from './types'
|
|
13
|
+
|
|
14
|
+
function useMotion(props: MotionProps & { bind?: (host: any) => void }) {
|
|
15
|
+
for (const name of ['drag', 'layout', 'layoutId', 'variants', 'whileTap', 'whileHover', 'whileFocus', 'whileInView']) {
|
|
16
|
+
if (name in props) throw new Error(`motion: ${name} is outside the supported subset`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const presence = useContext(PresenceContext)
|
|
20
|
+
if (props.exit && !presence) throw new Error('motion: exit requires a mounted Presence boundary')
|
|
21
|
+
const config = useContext(ConfigContext)
|
|
22
|
+
const systemReduced = useReducedMotion()
|
|
23
|
+
const reduced = config.reducedMotion === 'always' || (config.reducedMotion === 'user' && systemReduced)
|
|
24
|
+
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)
|
|
29
|
+
const latest = useRef(props)
|
|
30
|
+
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 }
|
|
33
|
+
const current = state.current
|
|
34
|
+
const style: Record<string, unknown> = {}
|
|
35
|
+
const bindings: [MotionKey, number | MotionValue][] = []
|
|
36
|
+
for (const [key, value] of Object.entries(props.style ?? {})) {
|
|
37
|
+
if (keys.includes(key as MotionKey)) {
|
|
38
|
+
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`)
|
|
40
|
+
bindings.push([key as MotionKey, value as number | MotionValue])
|
|
41
|
+
} else {
|
|
42
|
+
if (value instanceof MotionValue || ['transform', 'translateX', 'translateY', 'animation', 'transition'].includes(key)) throw new Error(`motion: unsupported or conflicting style ${key}`)
|
|
43
|
+
style[key] = value
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const previous = useRef(bindings)
|
|
48
|
+
const boundKeys = useRef<MotionKey[]>([])
|
|
49
|
+
const firstBinding = useRef(true)
|
|
50
|
+
const firstAnimation = useRef(true)
|
|
51
|
+
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) => {
|
|
54
|
+
if (!host) return
|
|
55
|
+
if (current.host !== host) {
|
|
56
|
+
current.adapter?.restore()
|
|
57
|
+
current.host = host
|
|
58
|
+
current.adapter = attachHost(host)
|
|
59
|
+
if (!current.initialized) {
|
|
60
|
+
const baseline = current.adapter.read()
|
|
61
|
+
for (const [key, value] of previous.current) baseline[key] = value instanceof MotionValue ? value.get() : value
|
|
62
|
+
current.controller.seed(baseline)
|
|
63
|
+
current.controller.seed(props.initial === false ? props.animate ?? {} : props.initial ?? {})
|
|
64
|
+
current.initialized = true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
current.controller.attach(current.adapter.write)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
current.controller.flush()
|
|
71
|
+
latest.current.bind?.(host)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const playback = useRef({ transition, reduced })
|
|
75
|
+
playback.current = { transition, reduced }
|
|
76
|
+
const returning = useRef<Target | null>(null)
|
|
77
|
+
const member = useRef<PresenceMember | null>(null)
|
|
78
|
+
if (!member.current) member.current = {
|
|
79
|
+
exit() {
|
|
80
|
+
const snapshot = current.controller.snapshot()
|
|
81
|
+
returning.current ??= {}
|
|
82
|
+
for (const key of Object.keys(latest.current.exit ?? {}) as MotionKey[]) {
|
|
83
|
+
if (!(key in returning.current)) returning.current[key] = snapshot[key]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return current.controller.animate(latest.current.exit ?? {}, playback.current.transition, playback.current.reduced)
|
|
87
|
+
},
|
|
88
|
+
enter() {
|
|
89
|
+
const target = { ...returning.current, ...latest.current.animate }
|
|
90
|
+
returning.current = null
|
|
91
|
+
void current.controller.animate(target, playback.current.transition, playback.current.reduced).then((result) => {
|
|
92
|
+
if (result === 'finished' && current.host && presence?.present) latest.current.onAnimationComplete?.()
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
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 }, [])
|
|
100
|
+
useLayoutEffect(() => {
|
|
101
|
+
const off: (() => void)[] = []
|
|
102
|
+
for (const key of boundKeys.current) if (!previous.current.some(([next]) => key === next)) current.controller.set(key, defaults[key])
|
|
103
|
+
boundKeys.current = previous.current.map(([key]) => key)
|
|
104
|
+
for (const [key, value] of previous.current) {
|
|
105
|
+
if (!firstBinding.current) current.controller.set(key, value instanceof MotionValue ? value.get() : value)
|
|
106
|
+
if (value instanceof MotionValue) off.push(value.on('change', (next) => current.controller.set(key, next)))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
firstBinding.current = false
|
|
110
|
+
return () => off.forEach((unsubscribe) => unsubscribe())
|
|
111
|
+
}, [previous.current])
|
|
112
|
+
|
|
113
|
+
useLayoutEffect(() => {
|
|
114
|
+
let active = true
|
|
115
|
+
const skip = firstAnimation.current && props.initial === false
|
|
116
|
+
firstAnimation.current = false
|
|
117
|
+
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
|
+
})
|
|
121
|
+
|
|
122
|
+
return () => { active = false; current.controller.stop() }
|
|
123
|
+
}, [JSON.stringify(props.animate), JSON.stringify(props.exit), JSON.stringify(transition), reduced])
|
|
124
|
+
|
|
125
|
+
const { initial, exit, animate, transition: ignoredTransition, onAnimationComplete, style: ignoredStyle, bind: ignoredBind, ...rest } = props
|
|
126
|
+
return { ...rest, style, bind: stableBind }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Animated View preserving the shared primitive's layout and accessibility. */
|
|
130
|
+
export function MotionView(props: MotionViewProps) @{
|
|
131
|
+
<View {...useMotion(props)}>{props.children}</View>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Animated horizontal Row. */
|
|
135
|
+
export function MotionRow(props: MotionRowProps) @{
|
|
136
|
+
<Row {...useMotion(props)}>{props.children}</Row>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Animated Pressable preserving its input callbacks and disabled state. */
|
|
140
|
+
export function MotionPressable(props: MotionPressableProps) @{
|
|
141
|
+
<Pressable {...useMotion(props)}>{props.children}</Pressable>
|
|
142
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { act, createRoot, useEffect } from 'octane'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { motion, MotionConfig, useMotionValue, useTransform, useSpring } from './index.web'
|
|
4
|
+
|
|
5
|
+
function Box(props: any) @{
|
|
6
|
+
<motion.View id="box" initial={{ x: 20 }} animate={props.target} transition={{ duration: 0 }} onAnimationComplete={props.done} />
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function Values(props: any) @{
|
|
10
|
+
const x = useMotionValue(2)
|
|
11
|
+
const doubled = useTransform(x, (value) => value * 2)
|
|
12
|
+
const spring = useSpring(x)
|
|
13
|
+
useEffect(() => { props.ready({ x, doubled, spring }) }, [])
|
|
14
|
+
|
|
15
|
+
<motion.Row id="values" style={{ x, scaleX: doubled }} />
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Reduced() @{
|
|
19
|
+
<MotionConfig reducedMotion="always"><motion.Pressable id="reduced" initial={{ x: 0 }} animate={{ x: 100 }} transition={{ duration: 10 }} /></MotionConfig>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mount(component: any) {
|
|
23
|
+
const container = document.createElement('div'); document.body.append(container)
|
|
24
|
+
const root = createRoot(container)
|
|
25
|
+
act(() => root.render(component))
|
|
26
|
+
return { container, root, close() { root.unmount(); container.remove() } }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('motion hosts', () => {
|
|
30
|
+
it('updates destinations without remounting and suppresses equal-target restarts', async () => {
|
|
31
|
+
const done = vi.fn(); const mounted = mount(<Box target={{ x: 50 }} done={done} />)
|
|
32
|
+
await act(async () => {})
|
|
33
|
+
const node = mounted.container.querySelector('#box') as HTMLElement
|
|
34
|
+
expect(node.style.transform).toContain('translateX(50px)')
|
|
35
|
+
expect(done).toHaveBeenCalledTimes(1)
|
|
36
|
+
act(() => mounted.root.render(<Box target={{ x: 50 }} done={done} />))
|
|
37
|
+
await act(async () => {})
|
|
38
|
+
expect(done).toHaveBeenCalledTimes(1)
|
|
39
|
+
act(() => mounted.root.render(<Box target={{ x: 80 }} done={done} />))
|
|
40
|
+
await act(async () => {})
|
|
41
|
+
expect(mounted.container.querySelector('#box')).toBe(node)
|
|
42
|
+
expect(node.style.transform).toContain('translateX(80px)')
|
|
43
|
+
mounted.close()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('binds and derives values without rerender and destroys owned values', () => {
|
|
47
|
+
let values: any
|
|
48
|
+
const mounted = mount(<Values ready={(next: any) => { values = next }} />)
|
|
49
|
+
act(() => values.x.set(10))
|
|
50
|
+
const node = mounted.container.querySelector('#values') as HTMLElement
|
|
51
|
+
expect(node.style.transform).toContain('translateX(10px)')
|
|
52
|
+
expect(node.style.transform).toContain('scaleX(20)')
|
|
53
|
+
values.spring.jump(7); expect(values.spring.get()).toBe(7)
|
|
54
|
+
mounted.close()
|
|
55
|
+
values.x.set(30); expect(values.x.get()).toBe(10)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reduces transforms through configuration', () => {
|
|
59
|
+
const mounted = mount(<Reduced />)
|
|
60
|
+
expect((mounted.container.querySelector('#reduced') as HTMLElement).style.transform).toContain('translateX(100px)')
|
|
61
|
+
mounted.close()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function InitialFalse() @{
|
|
66
|
+
<motion.View id="instant" initial={false} animate={{ x: 25 }} transition={{ duration: 100 }} />
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Binding(props: any) @{
|
|
70
|
+
<motion.View id="binding" style={props.style} />
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function DemoFixture() @{
|
|
74
|
+
<MotionDemo />
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
import { MotionDemo } from '../examples/MotionDemo.tsrx'
|
|
78
|
+
import { MotionValue } from './value'
|
|
79
|
+
import { clock } from './clock.web'
|
|
80
|
+
|
|
81
|
+
describe('binding lifecycle', () => {
|
|
82
|
+
it('initial=false applies the destination before playback', () => {
|
|
83
|
+
const mounted = mount(<InitialFalse />)
|
|
84
|
+
expect((mounted.container.querySelector('#instant') as HTMLElement).style.transform).toContain('translateX(25px)')
|
|
85
|
+
mounted.close()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('swaps values, removes bindings and preserves independent axes', () => {
|
|
89
|
+
const x = new MotionValue(10, clock), next = new MotionValue(20, clock)
|
|
90
|
+
const mounted = mount(<Binding style={{ x, rotate: 30 }} />)
|
|
91
|
+
const node = mounted.container.querySelector('#binding') as HTMLElement
|
|
92
|
+
act(() => mounted.root.render(<Binding style={{ x: next, rotate: 30 }} />))
|
|
93
|
+
x.set(60)
|
|
94
|
+
expect(node.style.transform).toContain('translateX(20px)')
|
|
95
|
+
act(() => mounted.root.render(<Binding style={{ rotate: 30 }} />))
|
|
96
|
+
next.set(80)
|
|
97
|
+
expect(node.style.transform).toContain('translateX(0px)')
|
|
98
|
+
expect(node.style.transform).toContain('rotate(30deg)')
|
|
99
|
+
mounted.close(); x.destroy(); next.destroy()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('mounts the maintained demonstration', () => {
|
|
103
|
+
const mounted = mount(<DemoFixture />)
|
|
104
|
+
expect(mounted.container.textContent).toContain('Drag, then release')
|
|
105
|
+
mounted.close()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
import { signal$ } from 'octane/signals'
|
|
110
|
+
const position$ = signal$(0)
|
|
111
|
+
function SignalBox() @{
|
|
112
|
+
<motion.View id="signal" animate={{ x: position$.get() }} transition={{ duration: 0 }} />
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it('retargets from a render-time signal read', () => {
|
|
116
|
+
const mounted = mount(<SignalBox />)
|
|
117
|
+
act(() => position$.set(40))
|
|
118
|
+
expect((mounted.container.querySelector('#signal') as HTMLElement).style.transform).toContain('translateX(40px)')
|
|
119
|
+
mounted.close(); position$.set(0)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
function UserReduced() @{
|
|
123
|
+
<MotionConfig reducedMotion="user"><motion.View id="user-reduced" animate={{ x: 100 }} transition={{ duration: 10 }} /></MotionConfig>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('observes live system reduced-motion changes', () => {
|
|
127
|
+
let reduced = false
|
|
128
|
+
const callbacks = new Set<() => void>()
|
|
129
|
+
vi.stubGlobal('matchMedia', () => ({ get matches() { return reduced }, addEventListener(_type: string, cb: () => void) { callbacks.add(cb) }, removeEventListener(_type: string, cb: () => void) { callbacks.delete(cb) } }))
|
|
130
|
+
const mounted = mount(<UserReduced />)
|
|
131
|
+
act(() => { reduced = true; callbacks.forEach((cb) => cb()) })
|
|
132
|
+
expect((mounted.container.querySelector('#user-reduced') as HTMLElement).style.transform).toContain('translateX(100px)')
|
|
133
|
+
mounted.close(); expect(callbacks.size).toBe(0); vi.unstubAllGlobals()
|
|
134
|
+
})
|
package/src/config.tsrx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState } from 'octane'
|
|
2
|
+
import type { MotionConfigProps } from './types'
|
|
3
|
+
import { readReducedMotion, observeReducedMotion } from './reduced-motion'
|
|
4
|
+
|
|
5
|
+
export const ConfigContext = createContext<MotionConfigProps>({ reducedMotion: 'never' })
|
|
6
|
+
|
|
7
|
+
/** Observe the live system preference, independent of MotionConfig overrides. */
|
|
8
|
+
export function useReducedMotion(): boolean {
|
|
9
|
+
const [reduced, setReduced] = useState(readReducedMotion)
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const update = () => setReduced(readReducedMotion())
|
|
12
|
+
const off = observeReducedMotion(update)
|
|
13
|
+
update()
|
|
14
|
+
return off
|
|
15
|
+
}, [])
|
|
16
|
+
|
|
17
|
+
return reduced
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Inherit transition and reduced-motion defaults within this Octane root. */
|
|
21
|
+
export function MotionConfig(props: MotionConfigProps) @{
|
|
22
|
+
const parent = useContext(ConfigContext)
|
|
23
|
+
const value = {
|
|
24
|
+
transition: props.transition ?? parent.transition,
|
|
25
|
+
reducedMotion: props.reducedMotion ?? parent.reducedMotion,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
<ConfigContext value={value}>{props.children}</ConfigContext>
|
|
29
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { MotionValue } from './value'
|
|
2
|
+
import type { Clock } from './clock-types'
|
|
3
|
+
import type { Target, MotionKey, Transition } from './types'
|
|
4
|
+
import type { AnimationResult } from './engine'
|
|
5
|
+
|
|
6
|
+
export const keys: MotionKey[] = ['opacity', 'x', 'y', 'scale', 'scaleX', 'scaleY', 'rotate']
|
|
7
|
+
export const defaults: Required<Target> = {
|
|
8
|
+
opacity: 1,
|
|
9
|
+
x: 0,
|
|
10
|
+
y: 0,
|
|
11
|
+
scale: 1,
|
|
12
|
+
scaleX: 1,
|
|
13
|
+
scaleY: 1,
|
|
14
|
+
rotate: 0,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateTarget(target?: Target) {
|
|
18
|
+
for (const [key, value] of Object.entries(target ?? {})) {
|
|
19
|
+
if (!keys.includes(key as MotionKey) || !Number.isFinite(value)) {
|
|
20
|
+
throw new Error(`motion: unsupported target ${key}; expected a finite numeric motion channel`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class Controller {
|
|
26
|
+
readonly values = new Map<MotionKey, MotionValue>()
|
|
27
|
+
private write?: (target: Target) => void
|
|
28
|
+
private generation = 0
|
|
29
|
+
constructor(private clock: Clock) {}
|
|
30
|
+
private value(key: MotionKey) {
|
|
31
|
+
let value = this.values.get(key)
|
|
32
|
+
if (!value) {
|
|
33
|
+
value = new MotionValue(defaults[key], this.clock)
|
|
34
|
+
this.values.set(key, value)
|
|
35
|
+
value.on('change', () => this.flush())
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
attach(write: (target: Target) => void) {
|
|
41
|
+
this.write = write
|
|
42
|
+
this.flush()
|
|
43
|
+
}
|
|
44
|
+
snapshot(): Target {
|
|
45
|
+
return Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()]))
|
|
46
|
+
}
|
|
47
|
+
flush() {
|
|
48
|
+
this.write?.(Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()])))
|
|
49
|
+
}
|
|
50
|
+
set(key: MotionKey, next: number) {
|
|
51
|
+
this.value(key).jump(next)
|
|
52
|
+
}
|
|
53
|
+
seed(target: Target) {
|
|
54
|
+
validateTarget(target)
|
|
55
|
+
for (const [key, value] of Object.entries(target)) {
|
|
56
|
+
this.set(key as MotionKey, value)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async animate(
|
|
60
|
+
target: Target,
|
|
61
|
+
transition: Transition,
|
|
62
|
+
reduced: boolean,
|
|
63
|
+
): Promise<AnimationResult> {
|
|
64
|
+
validateTarget(target)
|
|
65
|
+
const generation = ++this.generation
|
|
66
|
+
// A new destination owns the entire declarative animation, including removed keys.
|
|
67
|
+
for (const value of this.values.values()) {
|
|
68
|
+
value.stop('replaced')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const jobs = Object.entries(target).map(([key, to]) => {
|
|
72
|
+
const value = this.value(key as MotionKey)
|
|
73
|
+
return value.animate(to, reduced && key !== 'opacity' ? { duration: 0 } : transition).finished
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const results = await Promise.all(jobs)
|
|
77
|
+
if (generation !== this.generation) {
|
|
78
|
+
return 'replaced'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return results.every((result) => result === 'finished') ? 'finished' : 'cancelled'
|
|
82
|
+
}
|
|
83
|
+
stop() {
|
|
84
|
+
++this.generation
|
|
85
|
+
for (const value of this.values.values()) {
|
|
86
|
+
value.stop()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
destroy() {
|
|
90
|
+
this.stop()
|
|
91
|
+
for (const value of this.values.values()) {
|
|
92
|
+
value.destroy()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.values.clear()
|
|
96
|
+
this.write = undefined
|
|
97
|
+
}
|
|
98
|
+
}
|