@octane-xplat/motion 0.0.0 → 0.7.1

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "buildId": "16710a65-ed38-4b71-a217-8c555825a449",
4
+ "mode": "production",
5
+ "capabilities": {
6
+ "independentHydration": false
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,76 @@
1
1
  {
2
- "name": "@octane-xplat/motion",
3
- "version": "0.0.0",
4
- "description": "Stub reserving the name; real releases publish from CI via trusted publishing.",
5
- "publishConfig": {"access": "public"}
6
- }
2
+ "name": "@octane-xplat/motion",
3
+ "version": "0.7.1",
4
+ "description": "Declarative motion and retained presence for Octane on web, iOS and Android",
5
+ "keywords": [
6
+ "octane",
7
+ "xplat",
8
+ "cross-platform",
9
+ "animation",
10
+ "motion",
11
+ "transitions",
12
+ "spring",
13
+ "presence",
14
+ "nativescript",
15
+ "ios",
16
+ "android",
17
+ "web"
18
+ ],
19
+ "license": "MIT",
20
+ "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/motion",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/octane-xplat/octane-xplat.git",
24
+ "directory": "packages/motion"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "types",
29
+ "src",
30
+ "LICENSE.motion",
31
+ "UPSTREAM.md"
32
+ ],
33
+ "type": "module",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./types/index.d.ts",
37
+ "web": "./dist/web/index.js",
38
+ "native": "./dist/native/index.js",
39
+ "default": "./dist/native/index.js"
40
+ }
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "@octane-xplat/ui": "0.7.1",
47
+ "motion-dom": "12.42.2"
48
+ },
49
+ "devDependencies": {
50
+ "@nativescript-community/octane": "0.2.1",
51
+ "@nativescript/core": "9.1.2",
52
+ "@octanejs/vite-plugin": "0.1.61",
53
+ "jsdom": "30.1.1",
54
+ "octane": "0.6.3",
55
+ "typescript": "5.9.3",
56
+ "vite": "8.3.0",
57
+ "vitest": "5.0.1"
58
+ },
59
+ "peerDependencies": {
60
+ "@nativescript-community/octane": ">=0.2.1 <1",
61
+ "@nativescript/core": ">=9.1.0 <10",
62
+ "octane": ">=0.1.51 <1"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "@nativescript-community/octane": {
66
+ "optional": true
67
+ },
68
+ "@nativescript/core": {
69
+ "optional": true
70
+ }
71
+ },
72
+ "scripts": {
73
+ "build": "vite build && vite build --mode native && tsc -p tsconfig.types.json",
74
+ "test": "vitest run"
75
+ }
76
+ }
@@ -0,0 +1,6 @@
1
+ /** Frame scheduler; timestamps and elapsed time are milliseconds. */
2
+ export interface Clock {
3
+ now(): number
4
+ request(callback: () => void): number
5
+ cancel(id: number): void
6
+ }
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
+ }
@@ -0,0 +1,6 @@
1
+ import type { Clock } from './clock-types'
2
+ export const clock: Clock = {
3
+ now: () => performance.now(),
4
+ request: (callback) => requestAnimationFrame(callback),
5
+ cancel: (id) => cancelAnimationFrame(id),
6
+ }
@@ -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
+ })
@@ -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
+ }