@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
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { attachDrag, validateDrag } from './drag'
3
+ import { Controller } from './controller'
4
+ import type { Clock } from './clock-types'
5
+ import type { PanCallbacks, HostAdapter } from './host-types'
6
+ import type { DragInfo, MotionProps } from './types'
7
+
8
+ function fixture(props: MotionProps, reduced = false) {
9
+ let time = 0
10
+ let id = 0
11
+ const frames = new Map<number, () => void>()
12
+ const clock: Clock = {
13
+ now: () => time,
14
+ request: (fn) => {
15
+ frames.set(++id, fn)
16
+ return id
17
+ },
18
+ cancel: (key) => {
19
+ frames.delete(key)
20
+ },
21
+ }
22
+
23
+ const controller = new Controller(clock)
24
+ controller.seed({ x: 10, y: 20 })
25
+ let callbacks: PanCallbacks
26
+ const detach = vi.fn()
27
+ const delegate = vi.fn()
28
+ const writes: number[] = []
29
+ const adapter: HostAdapter = {
30
+ read: () => ({}),
31
+ write: (next) => {
32
+ writes.push(next.x ?? 0)
33
+ },
34
+ restore() {},
35
+ delegate,
36
+ pan(_axis, cb) {
37
+ callbacks = cb
38
+ return detach
39
+ },
40
+ }
41
+
42
+ controller.attach(adapter)
43
+ const off = attachDrag(
44
+ adapter,
45
+ controller,
46
+ props,
47
+ (axis) => controller.value(axis),
48
+ () => reduced,
49
+ () => props,
50
+ )
51
+
52
+ return {
53
+ controller,
54
+ off,
55
+ detach,
56
+ delegate,
57
+ writes,
58
+ emit(phase: keyof PanCallbacks, x: number, velocity = 0, cancelled = false) {
59
+ const info: DragInfo = {
60
+ point: { x, y: 0 },
61
+ offset: { x, y: x },
62
+ delta: { x, y: 0 },
63
+ velocity: { x: velocity, y: velocity },
64
+ cancelled,
65
+ }
66
+
67
+ callbacks[phase]({}, info)
68
+ },
69
+ advance(ms: number) {
70
+ time += ms
71
+ const pending = [...frames.values()]
72
+ frames.clear()
73
+ pending.forEach((fn) => fn())
74
+ },
75
+ count: () => frames.size,
76
+ }
77
+ }
78
+
79
+ describe('bounded drag', () => {
80
+ it('clamps live displacement, keeps the other axis, and emits callbacks once', () => {
81
+ const onDragStart = vi.fn(),
82
+ onDrag = vi.fn(),
83
+ onDragEnd = vi.fn()
84
+
85
+ const f = fixture({
86
+ drag: 'x',
87
+ dragConstraints: { left: -20, right: 30 },
88
+ dragElastic: 0,
89
+ dragMomentum: false,
90
+ onDragStart,
91
+ onDrag,
92
+ onDragEnd,
93
+ })
94
+
95
+ f.emit('start', 0)
96
+ f.emit('move', 100)
97
+ expect(f.controller.snapshot()).toEqual({ x: 30, y: 20 })
98
+ f.emit('end', 100, 500)
99
+ f.emit('end', 100)
100
+ expect(onDragStart).toHaveBeenCalledTimes(1)
101
+ expect(onDrag).toHaveBeenCalledTimes(1)
102
+ expect(onDragEnd).toHaveBeenCalledTimes(1)
103
+ expect(f.count()).toBe(0)
104
+ f.off()
105
+ expect(f.detach).toHaveBeenCalledOnce()
106
+ })
107
+
108
+ it('uses default elasticity and springs back inside constraints without momentum', () => {
109
+ const f = fixture({ drag: 'x', dragConstraints: { right: 30 }, dragMomentum: false })
110
+ f.emit('start', 0)
111
+ f.emit('move', 100)
112
+ expect(f.controller.value('x').get()).toBe(58)
113
+ f.emit('end', 100)
114
+ for (let i = 0; i < 200; i++) {
115
+ f.advance(16)
116
+ }
117
+
118
+ expect(f.controller.value('x').get()).toBe(30)
119
+ expect(f.delegate).not.toHaveBeenCalled()
120
+ f.off()
121
+ })
122
+
123
+ it('hands release velocity to a JS spring and bounds every hard-constraint sample', () => {
124
+ const f = fixture({ drag: 'x', dragConstraints: { left: -20, right: 50 }, dragElastic: false })
125
+ f.emit('start', 0)
126
+ f.emit('move', 10)
127
+ f.emit('end', 10, 500)
128
+ f.advance(16)
129
+ expect(f.controller.value('x').get()).toBeGreaterThan(20)
130
+ for (let i = 0; i < 200; i++) {
131
+ f.advance(16)
132
+ }
133
+
134
+ expect(f.controller.value('x').get()).toBe(50)
135
+ expect(Math.max(...f.writes)).toBeLessThanOrEqual(50)
136
+ expect(f.delegate).not.toHaveBeenCalled()
137
+ f.off()
138
+ })
139
+
140
+ it('cancellation drops momentum; reduced motion snaps; teardown cancels settlement', () => {
141
+ const cancel = fixture({ drag: true })
142
+ cancel.emit('start', 0)
143
+ cancel.emit('move', 20)
144
+ cancel.emit('end', 20, 500, true)
145
+ expect(cancel.count()).toBe(0)
146
+ cancel.off()
147
+ const reduced = fixture({ drag: 'x' }, true)
148
+ reduced.emit('start', 0)
149
+ reduced.emit('end', 10, 500)
150
+ expect(reduced.controller.value('x').get()).toBe(120)
151
+ expect(reduced.count()).toBe(0)
152
+ reduced.off()
153
+ const f = fixture({ drag: 'x' })
154
+ f.emit('start', 0)
155
+ f.emit('end', 10, 500)
156
+ expect(f.count()).toBe(1)
157
+ f.off()
158
+ expect(f.count()).toBe(0)
159
+ })
160
+
161
+ it('rejects refs, invalid bounds, elasticity and competing axis targets', () => {
162
+ expect(() => validateDrag({ dragConstraints: { current: null } } as any)).toThrow('refs')
163
+ expect(() => validateDrag({ dragConstraints: { left: 20, right: 10 } })).toThrow('reversed')
164
+ expect(() => validateDrag({ dragElastic: 2 })).toThrow('dragElastic')
165
+ expect(() => validateDrag({ drag: 'x', animate: { x: 20 } })).toThrow('both own x')
166
+ })
167
+ })
package/src/drag.ts ADDED
@@ -0,0 +1,157 @@
1
+ import type { Controller } from './controller'
2
+ import type { HostAdapter } from './host-types'
3
+ import type { DragInfo, MotionProps } from './types'
4
+ import type { MotionValue } from './value'
5
+
6
+ export function validateDrag(props: MotionProps) {
7
+ if (props.drag !== undefined && ![true, false, 'x', 'y'].includes(props.drag)) {
8
+ throw new Error('motion: drag must be boolean, x, or y')
9
+ }
10
+
11
+ const bounds = props.dragConstraints ?? {}
12
+ for (const [key, value] of Object.entries(bounds)) {
13
+ if (!['left', 'right', 'top', 'bottom'].includes(key) || !Number.isFinite(value)) {
14
+ throw new Error('motion: dragConstraints requires finite numeric edges; refs are deferred')
15
+ }
16
+ }
17
+
18
+ if (
19
+ (bounds.left ?? -Infinity) > (bounds.right ?? Infinity) ||
20
+ (bounds.top ?? -Infinity) > (bounds.bottom ?? Infinity)
21
+ ) {
22
+ throw new Error('motion: dragConstraints edges are reversed')
23
+ }
24
+
25
+ if (
26
+ typeof props.dragElastic !== 'boolean' &&
27
+ props.dragElastic !== undefined &&
28
+ (!Number.isFinite(props.dragElastic) || props.dragElastic < 0 || props.dragElastic > 1)
29
+ ) {
30
+ throw new Error('motion: dragElastic must be boolean or a number from 0 to 1')
31
+ }
32
+
33
+ if (props.drag) {
34
+ for (const axis of ['x', 'y'] as const) {
35
+ if (props.drag !== true && props.drag !== axis) {
36
+ continue
37
+ }
38
+
39
+ for (const target of [props.animate, props.whileTap, props.whileFocus]) {
40
+ if (
41
+ target &&
42
+ typeof target === 'object' &&
43
+ !Array.isArray(target) &&
44
+ target[axis] !== undefined
45
+ ) {
46
+ throw new Error(`motion: drag and animation both own ${axis}`)
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
54
+
55
+ /** Gesture writes and release springs bypass host delegation. */
56
+ export function attachDrag(
57
+ adapter: HostAdapter,
58
+ controller: Controller,
59
+ props: MotionProps,
60
+ valueFor: (axis: 'x' | 'y') => MotionValue,
61
+ reduced: () => boolean,
62
+ callbacks: () => MotionProps,
63
+ ) {
64
+ if (!props.drag) {
65
+ return () => {}
66
+ }
67
+
68
+ if (!adapter.pan) {
69
+ throw new Error('motion: this host does not support drag')
70
+ }
71
+
72
+ const axes = (['x', 'y'] as const).filter((axis) => props.drag === true || props.drag === axis)
73
+ const values = new Map(axes.map((axis) => [axis, valueFor(axis)]))
74
+ const value = (axis: 'x' | 'y') => values.get(axis)!
75
+ const bounds = props.dragConstraints ?? {}
76
+ const elastic =
77
+ typeof props.dragElastic === 'number'
78
+ ? props.dragElastic
79
+ : props.dragElastic === false
80
+ ? 0
81
+ : 0.35
82
+
83
+ const origin = { x: 0, y: 0 }
84
+ let active = false
85
+ const limits = (axis: 'x' | 'y') =>
86
+ axis === 'x'
87
+ ? [bounds.left ?? -Infinity, bounds.right ?? Infinity]
88
+ : [bounds.top ?? -Infinity, bounds.bottom ?? Infinity]
89
+
90
+ const move = (info: DragInfo) => {
91
+ for (const axis of axes) {
92
+ const [min, max] = limits(axis)
93
+ const next = origin[axis] + info.offset[axis]
94
+ const bounded = clamp(next, min, max)
95
+ value(axis).jump(bounded + (next - bounded) * elastic)
96
+ }
97
+ }
98
+
99
+ const off = adapter.pan(props.drag, {
100
+ start(event, info) {
101
+ controller.stop()
102
+ active = true
103
+ for (const axis of axes) {
104
+ const channel = value(axis)
105
+ channel.stop()
106
+ origin[axis] = channel.get()
107
+ }
108
+
109
+ callbacks().onDragStart?.(event, info)
110
+ },
111
+ move(event, info) {
112
+ if (!active) {
113
+ return
114
+ }
115
+
116
+ move(info)
117
+ callbacks().onDrag?.(event, info)
118
+ },
119
+ end(event, info) {
120
+ if (!active) {
121
+ return
122
+ }
123
+
124
+ active = false
125
+ move(info)
126
+ for (const axis of axes) {
127
+ const channel = value(axis)
128
+ const velocity = info.cancelled || props.dragMomentum === false ? 0 : info.velocity[axis]
129
+ const [min, max] = limits(axis)
130
+ const target = clamp(channel.get() + velocity * 0.2, min, max)
131
+ if (elastic === 0) {
132
+ channel.jump(clamp(channel.get(), min, max))
133
+ }
134
+
135
+ if (reduced()) {
136
+ channel.jump(target)
137
+ } else if (target !== channel.get() || velocity !== 0) {
138
+ channel.animate(
139
+ target,
140
+ { type: 'spring', stiffness: 200, damping: 30, velocity },
141
+ elastic === 0 ? (next) => clamp(next, min, max) : undefined,
142
+ )
143
+ }
144
+ }
145
+
146
+ callbacks().onDragEnd?.(event, info)
147
+ },
148
+ })
149
+
150
+ return () => {
151
+ active = false
152
+ off()
153
+ for (const channel of values.values()) {
154
+ channel.stop()
155
+ }
156
+ }
157
+ }
package/src/driver.ts ADDED
@@ -0,0 +1,249 @@
1
+ import type { DelegatedRequest, DelegatedRun } from './host-types'
2
+ import { bezierPoints, isBezierEase } from './engine'
3
+ import { delegationStats, trackDelegatedRun } from './delegation'
4
+ import type { Target } from './types'
5
+
6
+ const platform = globalThis as any
7
+
8
+ function iosRun(node: any, req: DelegatedRequest, write: (t: Target) => void): DelegatedRun | null {
9
+ const view = node.ios ?? node.nativeViewProtected
10
+ if (!view?.layer || !platform.UIViewPropertyAnimator) {
11
+ return null
12
+ }
13
+
14
+ const { eff, target, transition: t } = req
15
+ const [x1, y1, x2, y2] = bezierPoints(t.ease)
16
+ const params = platform.UICubicTimingParameters.alloc().initWithControlPoint1ControlPoint2(
17
+ platform.CGPointMake(x1, y1),
18
+ platform.CGPointMake(x2, y2),
19
+ )
20
+
21
+ const animator = platform.UIViewPropertyAnimator.alloc().initWithDurationTimingParameters(
22
+ t.duration ?? 0.3,
23
+ params,
24
+ )
25
+
26
+ // UIViewPropertyAnimator owns view.transform/alpha presentation; the model
27
+ // props land at the destination immediately, so sample() reads the
28
+ // presentation layer and cancel() syncs the model back before stopping.
29
+ const radians = ((eff.rotate ?? 0) * Math.PI) / 180
30
+ const dest = platform.CGAffineTransformScale(
31
+ platform.CGAffineTransformRotate(
32
+ platform.CGAffineTransformMakeTranslation(eff.x ?? 0, eff.y ?? 0),
33
+ radians,
34
+ ),
35
+ eff.scaleX ?? 1,
36
+ eff.scaleY ?? 1,
37
+ )
38
+
39
+ const sample = (): Target => {
40
+ const layer = view.layer.presentationLayer?.() ?? view.layer
41
+ const tr = layer.transform
42
+ return {
43
+ opacity: layer.opacity,
44
+ x: tr.m41,
45
+ y: tr.m42,
46
+ scaleX: Math.hypot(tr.m11, tr.m12),
47
+ scaleY: Math.hypot(tr.m21, tr.m22),
48
+ rotate: (Math.atan2(tr.m12, tr.m11) * 180) / Math.PI,
49
+ }
50
+ }
51
+
52
+ let resolve!: (result: 'finished' | 'cancelled') => void
53
+ const finished = new Promise<'finished' | 'cancelled'>((done) => {
54
+ resolve = done
55
+ })
56
+
57
+ let settled = false
58
+ const settle = (result: 'finished' | 'cancelled') => {
59
+ if (!settled) {
60
+ settled = true
61
+ resolve(result)
62
+ }
63
+ }
64
+
65
+ animator.addAnimations(() => {
66
+ if (
67
+ target.x !== undefined ||
68
+ target.y !== undefined ||
69
+ target.rotate !== undefined ||
70
+ target.scale !== undefined ||
71
+ target.scaleX !== undefined ||
72
+ target.scaleY !== undefined
73
+ ) {
74
+ view.transform = dest
75
+ }
76
+
77
+ if (target.opacity !== undefined) {
78
+ view.alpha = Math.min(1, Math.max(0, eff.opacity ?? target.opacity))
79
+ }
80
+ })
81
+
82
+ animator.addCompletion((position: number) => {
83
+ if (position === 0) {
84
+ write(req.dest)
85
+ settle('finished')
86
+ } else {
87
+ settle('cancelled')
88
+ }
89
+ })
90
+
91
+ if (t.delay) {
92
+ animator.startAnimationAfterDelay(t.delay)
93
+ } else {
94
+ animator.startAnimation()
95
+ }
96
+
97
+ return {
98
+ finished,
99
+ sample,
100
+ cancel() {
101
+ const current = sample()
102
+ animator.stopAnimation(true)
103
+ write({
104
+ opacity: current.opacity,
105
+ x: current.x,
106
+ y: current.y,
107
+ scaleX: current.scaleX,
108
+ scaleY: current.scaleY,
109
+ rotate: current.rotate,
110
+ })
111
+
112
+ settle('cancelled')
113
+ },
114
+ }
115
+ }
116
+
117
+ function androidRun(
118
+ node: any,
119
+ req: DelegatedRequest,
120
+ write: (t: Target) => void,
121
+ ): DelegatedRun | null {
122
+ const view = node.nativeViewProtected ?? node.android
123
+ const android = platform.android
124
+ const androidx = platform.androidx
125
+ if (
126
+ !view?.animate ||
127
+ !android?.animation ||
128
+ !androidx?.core?.view?.animation?.PathInterpolatorCompat
129
+ ) {
130
+ return null
131
+ }
132
+
133
+ const { eff, target, transition: t } = req
134
+ const animator = view.animate()
135
+ const [x1, y1, x2, y2] = bezierPoints(t.ease)
136
+ animator.setInterpolator(
137
+ androidx.core.view.animation.PathInterpolatorCompat.create(x1, y1, x2, y2),
138
+ )
139
+
140
+ animator.setDuration(Math.round((t.duration ?? 0.3) * 1000))
141
+ animator.setStartDelay(Math.round((t.delay ?? 0) * 1000))
142
+ if (target.x !== undefined) {
143
+ animator.translationX(eff.x)
144
+ }
145
+
146
+ if (target.y !== undefined) {
147
+ animator.translationY(eff.y)
148
+ }
149
+
150
+ if (target.scale !== undefined || target.scaleX !== undefined) {
151
+ animator.scaleX(eff.scaleX)
152
+ }
153
+
154
+ if (target.scale !== undefined || target.scaleY !== undefined) {
155
+ animator.scaleY(eff.scaleY)
156
+ }
157
+
158
+ if (target.rotate !== undefined) {
159
+ animator.rotation(eff.rotate)
160
+ }
161
+
162
+ if (target.opacity !== undefined) {
163
+ animator.alpha(Math.min(1, Math.max(0, eff.opacity ?? target.opacity)))
164
+ }
165
+
166
+ let resolve!: (result: 'finished' | 'cancelled') => void
167
+ const finished = new Promise<'finished' | 'cancelled'>((done) => {
168
+ resolve = done
169
+ })
170
+
171
+ let settled = false
172
+ const settle = (result: 'finished' | 'cancelled') => {
173
+ if (!settled) {
174
+ settled = true
175
+ resolve(result)
176
+ }
177
+ }
178
+
179
+ // onAnimationCancel is always followed by onAnimationEnd — track it so the
180
+ // end callback only commits the destination for a run that truly finished.
181
+ let cancelled = false
182
+ animator.setListener(
183
+ new android.animation.Animator.AnimatorListener({
184
+ onAnimationCancel() {
185
+ cancelled = true
186
+ settle('cancelled')
187
+ },
188
+ onAnimationEnd() {
189
+ if (!cancelled) {
190
+ // ViewPropertyAnimator leaves native props at the destination;
191
+ // committing keeps the NativeScript property cache coherent.
192
+ write(req.dest)
193
+ }
194
+
195
+ settle(cancelled ? 'cancelled' : 'finished')
196
+ },
197
+ onAnimationStart() {},
198
+ onAnimationRepeat() {},
199
+ }),
200
+ )
201
+
202
+ animator.start()
203
+
204
+ return {
205
+ finished,
206
+ sample: () => ({
207
+ opacity: view.getAlpha(),
208
+ x: view.getTranslationX(),
209
+ y: view.getTranslationY(),
210
+ scaleX: view.getScaleX(),
211
+ scaleY: view.getScaleY(),
212
+ rotate: view.getRotation(),
213
+ }),
214
+ cancel() {
215
+ animator.cancel()
216
+ },
217
+ }
218
+ }
219
+
220
+ // Platform animators drive the presentation while MotionValues track sampled
221
+ // state; interruption stays velocity-exact on the JS engine. Falls back to
222
+ // null anywhere the platform object is missing (tests, unknown targets).
223
+ export function delegatedRun(
224
+ node: any,
225
+ req: DelegatedRequest,
226
+ write: (t: Target) => void,
227
+ ): DelegatedRun | null {
228
+ // Dispatch on platform-object presence — no @nativescript/core import, so
229
+ // this module stays loadable in the object-driver test environment. Eases
230
+ // that are not cubic-bezier-expressible stay on the JS engine.
231
+ if (!isBezierEase(req.transition.ease)) {
232
+ delegationStats().fallback++
233
+ return null
234
+ }
235
+
236
+ const run =
237
+ node.ios && platform.UIViewPropertyAnimator
238
+ ? iosRun(node, req, write)
239
+ : platform.android?.animation && (node.nativeViewProtected ?? node.android)
240
+ ? androidRun(node, req, write)
241
+ : null
242
+
243
+ if (!run) {
244
+ delegationStats().fallback++
245
+ return null
246
+ }
247
+
248
+ return trackDelegatedRun(run)
249
+ }