@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,269 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Controller } from './controller'
3
+ import type { Clock } from './clock-types'
4
+ import type { DelegatedRequest, DelegatedRun, HostAdapter } from './host-types'
5
+ import type { Target } from './types'
6
+
7
+ function fakeClock() {
8
+ let time = 0,
9
+ id = 0
10
+
11
+ const callbacks = new Map<number, () => void>()
12
+ const clock: Clock = {
13
+ now: () => time,
14
+ request: (fn) => {
15
+ callbacks.set(++id, fn)
16
+ return id
17
+ },
18
+ cancel: (key) => {
19
+ callbacks.delete(key)
20
+ },
21
+ }
22
+
23
+ return {
24
+ clock,
25
+ advance(ms: number) {
26
+ time += ms
27
+ const pending = [...callbacks.values()]
28
+ callbacks.clear()
29
+ pending.forEach((fn) => fn())
30
+ },
31
+ count: () => callbacks.size,
32
+ }
33
+ }
34
+
35
+ interface FakeRun extends DelegatedRun {
36
+ resolve(result: 'finished' | 'cancelled'): void
37
+ setEff(next: Target): void
38
+ cancelCalls: number
39
+ }
40
+
41
+ function fakeRun(eff: Target): FakeRun {
42
+ let resolve!: (result: 'finished' | 'cancelled') => void
43
+ let current = { ...eff }
44
+ const run: FakeRun = {
45
+ finished: new Promise((done) => {
46
+ resolve = done
47
+ }),
48
+ sample: () => ({ ...current }),
49
+ cancelCalls: 0,
50
+ cancel() {
51
+ run.cancelCalls++
52
+ run.resolve('cancelled')
53
+ },
54
+ resolve,
55
+ setEff(next) {
56
+ current = { ...next }
57
+ },
58
+ }
59
+
60
+ return run
61
+ }
62
+
63
+ function stubHost(onDelegate?: (req: DelegatedRequest) => DelegatedRun | null) {
64
+ const written: Target[] = []
65
+ const adapter: HostAdapter = {
66
+ read: () => ({ opacity: 1, x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0 }),
67
+ write: (target) => {
68
+ written.push({ ...target })
69
+ },
70
+ restore: () => {},
71
+ delegate: onDelegate,
72
+ }
73
+
74
+ return { adapter, written }
75
+ }
76
+
77
+ describe('delegated runs', () => {
78
+ it('hands a tween to the host driver and resolves finished at the destination', async () => {
79
+ const time = fakeClock()
80
+ const controller = new Controller(time.clock)
81
+ let request: DelegatedRequest | undefined
82
+ const run = fakeRun({ x: 0 })
83
+ const { adapter } = stubHost((req) => {
84
+ request = req
85
+ return run
86
+ })
87
+
88
+ controller.attach(adapter)
89
+ controller.seed({ x: 0 })
90
+
91
+ const done = controller.animate({ x: 100 }, { duration: 0.5 }, false)
92
+ expect(request?.eff.x).toBe(100)
93
+ expect(request?.target.x).toBe(100)
94
+
95
+ run.setEff({ x: 40 })
96
+ time.advance(16)
97
+ run.resolve('finished')
98
+ expect(await done).toBe('finished')
99
+ expect(controller.values.get('x')?.get()).toBe(100)
100
+ expect(time.count()).toBe(0)
101
+ })
102
+
103
+ it('folds scale into the effective axis and tracks samples back', async () => {
104
+ const time = fakeClock()
105
+ const controller = new Controller(time.clock)
106
+ let request: DelegatedRequest | undefined
107
+ const run = fakeRun({ scaleX: 0.5, scaleY: 0.5 })
108
+ const { adapter } = stubHost((req) => {
109
+ request = req
110
+ return run
111
+ })
112
+
113
+ controller.attach(adapter)
114
+ controller.seed({ scaleX: 0.5 })
115
+
116
+ const done = controller.animate({ scale: 2 }, { duration: 0.4 }, false)
117
+ // Retained scaleX=0.5 folds into the effective destination.
118
+ expect(request?.eff.scaleX).toBe(1)
119
+ expect(request?.eff.scaleY).toBe(2)
120
+ run.setEff({ scaleX: 0.9, scaleY: 0.9 })
121
+ time.advance(16)
122
+ // scale channel tracks effective / retained scaleX.
123
+ expect(controller.values.get('scale')?.get()).toBeCloseTo(1.8)
124
+ run.resolve('finished')
125
+ expect(await done).toBe('finished')
126
+ expect(controller.values.get('scale')?.get()).toBe(2)
127
+ })
128
+
129
+ it('interrupts a delegated run with a tracked sample and resolves replaced', async () => {
130
+ const time = fakeClock()
131
+ const controller = new Controller(time.clock)
132
+ const first = fakeRun({ x: 0 })
133
+ const second = fakeRun({ x: 30 })
134
+ const runs = [first, second]
135
+ let calls = 0
136
+ const { adapter } = stubHost(() => runs[calls++ % runs.length])
137
+ controller.attach(adapter)
138
+ controller.seed({ x: 0 })
139
+
140
+ const doneFirst = controller.animate({ x: 100 }, { duration: 0.5 }, false)
141
+ first.setEff({ x: 30 })
142
+ time.advance(16)
143
+ const doneSecond = controller.animate({ x: -50 }, { duration: 0.5 }, false)
144
+ expect(first.cancelCalls).toBe(1)
145
+ // The interruption sample is written into the channel value.
146
+ expect(controller.values.get('x')?.get()).toBe(30)
147
+ expect(await doneFirst).toBe('replaced')
148
+ second.setEff({ x: -20 })
149
+ time.advance(16)
150
+ expect(controller.values.get('x')?.get()).toBe(-20)
151
+ second.resolve('finished')
152
+ expect(await doneSecond).toBe('finished')
153
+ expect(controller.values.get('x')?.get()).toBe(-50)
154
+ })
155
+
156
+ it('keeps springs on the JS engine', async () => {
157
+ const time = fakeClock()
158
+ const controller = new Controller(time.clock)
159
+ let delegated = false
160
+ const { adapter } = stubHost(() => {
161
+ delegated = true
162
+ return null
163
+ })
164
+
165
+ controller.attach(adapter)
166
+ controller.seed({ x: 0 })
167
+
168
+ const done = controller.animate(
169
+ { x: 100 },
170
+ { type: 'spring', stiffness: 100, damping: 10 },
171
+ false,
172
+ )
173
+
174
+ expect(delegated).toBe(false)
175
+ for (let i = 0; i < 400; i++) {
176
+ time.advance(16)
177
+ }
178
+
179
+ expect(await done).toBe('finished')
180
+ expect(controller.values.get('x')?.get()).toBe(100)
181
+ })
182
+
183
+ it('keeps reduced-motion runs on the JS engine for instant settle', async () => {
184
+ const time = fakeClock()
185
+ const controller = new Controller(time.clock)
186
+ let delegated = false
187
+ const { adapter, written } = stubHost(() => {
188
+ delegated = true
189
+ return null
190
+ })
191
+
192
+ controller.attach(adapter)
193
+ controller.seed({ x: 0 })
194
+
195
+ const done = controller.animate({ x: 50 }, { duration: 0.5 }, true)
196
+ expect(delegated).toBe(false)
197
+ expect(await done).toBe('finished')
198
+ expect(controller.values.get('x')?.get()).toBe(50)
199
+ expect(written[written.length - 1]?.x).toBe(50)
200
+ })
201
+
202
+ it('resolves replaced when the delegated run is stopped', async () => {
203
+ const time = fakeClock()
204
+ const controller = new Controller(time.clock)
205
+ const run = fakeRun({ x: 0 })
206
+ const { adapter } = stubHost(() => run)
207
+ controller.attach(adapter)
208
+ controller.seed({ x: 0 })
209
+
210
+ const done = controller.animate({ x: 100 }, { duration: 0.5 }, false)
211
+ controller.stop()
212
+ expect(run.cancelCalls).toBe(1)
213
+ expect(await done).toBe('replaced')
214
+ })
215
+
216
+ it.each([
217
+ ['per-key transitions', { default: { duration: 0.5 }, x: { duration: 1 } }],
218
+ ['a non-bezier ease', { duration: 0.5, ease: 'backOut' as const }],
219
+ ['a repeating run', { duration: 0.5, repeat: 2 }],
220
+ ])('stays on the JS engine for %s', async (_label, transition) => {
221
+ const time = fakeClock()
222
+ const controller = new Controller(time.clock)
223
+ let delegated = false
224
+ const { adapter } = stubHost(() => {
225
+ delegated = true
226
+ return null
227
+ })
228
+
229
+ controller.attach(adapter)
230
+ controller.seed({ x: 0 })
231
+
232
+ const done = controller.animate({ x: 100 }, transition as any, false)
233
+ expect(delegated).toBe(false)
234
+ for (let i = 0; i < 500; i++) {
235
+ time.advance(16)
236
+ }
237
+
238
+ expect(await done).toBe('finished')
239
+ expect(controller.values.get('x')?.get()).toBe(100)
240
+ })
241
+
242
+ it('applies per-key overrides to each channel independently', async () => {
243
+ const time = fakeClock()
244
+ const controller = new Controller(time.clock)
245
+ const { adapter } = stubHost()
246
+ controller.attach(adapter)
247
+ controller.seed({ x: 0, y: 0 })
248
+
249
+ const done = controller.animate(
250
+ { x: 100, y: 100 },
251
+ { default: { duration: 0.1, ease: 'linear' }, x: { duration: 0.3 } },
252
+ false,
253
+ )
254
+
255
+ for (let i = 0; i < 8; i++) {
256
+ time.advance(16)
257
+ }
258
+
259
+ // y finished at 100ms; x still travelling on its 300ms override.
260
+ expect(controller.values.get('y')?.get()).toBe(100)
261
+ expect(controller.values.get('x')?.get()).toBeLessThan(100)
262
+ for (let i = 0; i < 30; i++) {
263
+ time.advance(16)
264
+ }
265
+
266
+ expect(await done).toBe('finished')
267
+ expect(controller.values.get('x')?.get()).toBe(100)
268
+ })
269
+ })
@@ -0,0 +1,29 @@
1
+ import type { DelegatedRun } from './host-types'
2
+
3
+ // Probe-visible counters so retained probes can distinguish a real delegated
4
+ // run from a silent JS-engine fallback without widening the public API.
5
+ export function delegationStats() {
6
+ const platform = globalThis as any
7
+ return (platform.__xplatMotionDelegations ??= {
8
+ started: 0,
9
+ finished: 0,
10
+ cancelled: 0,
11
+ fallback: 0,
12
+ })
13
+ }
14
+
15
+ /** Count a delegated run and fold its settled result into the counters. */
16
+ export function trackDelegatedRun(run: DelegatedRun): DelegatedRun {
17
+ delegationStats().started++
18
+ const finished = run.finished.then((result) => {
19
+ if (result === 'finished') {
20
+ delegationStats().finished++
21
+ } else {
22
+ delegationStats().cancelled++
23
+ }
24
+
25
+ return result
26
+ })
27
+
28
+ return { ...run, finished }
29
+ }
@@ -0,0 +1,270 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { Controller } from './controller'
3
+ import { delegatedRun } from './driver.web'
4
+ import { attachHost } from './host.web'
5
+ import type { Clock } from './clock-types'
6
+ import type { Target } from './types'
7
+
8
+ function fakeClock() {
9
+ let time = 0,
10
+ id = 0
11
+
12
+ const callbacks = new Map<number, () => void>()
13
+ const clock: Clock = {
14
+ now: () => time,
15
+ request: (fn) => {
16
+ callbacks.set(++id, fn)
17
+ return id
18
+ },
19
+ cancel: (key) => {
20
+ callbacks.delete(key)
21
+ },
22
+ }
23
+
24
+ return {
25
+ clock,
26
+ advance(ms: number) {
27
+ time += ms
28
+ const pending = [...callbacks.values()]
29
+ callbacks.clear()
30
+ pending.forEach((fn) => fn())
31
+ },
32
+ count: () => callbacks.size,
33
+ }
34
+ }
35
+
36
+ // Web delegated driver — jsdom ships no `element.animate`, so a fake
37
+ // Animation models the browser: `presented` stands in for the animation
38
+ // cascade over the element's base styles, and cancel() reveals the base
39
+ // again like a real reverted WAAPI run.
40
+ function styleToMatrix(style: string): string {
41
+ if (!style || style === 'none' || style.startsWith('matrix')) {
42
+ return style || 'none'
43
+ }
44
+
45
+ const channel = (name: string, dflt: number) => {
46
+ const match = new RegExp(`${name}\\((-?[\\d.eE+-]+)`).exec(style)
47
+ return match ? Number.parseFloat(match[1]) : dflt
48
+ }
49
+
50
+ const x = channel('translateX', 0)
51
+ const y = channel('translateY', 0)
52
+ const sx = channel('scale', 1) * channel('scaleX', 1)
53
+ const sy = channel('scale', 1) * channel('scaleY', 1)
54
+ const rad = (channel('rotate', 0) * Math.PI) / 180
55
+ return `matrix(${sx * Math.cos(rad)}, ${sy * Math.sin(rad)}, ${-sx * Math.sin(rad)}, ${sy * Math.cos(rad)}, ${x}, ${y})`
56
+ }
57
+
58
+ function fakeWebElement() {
59
+ const animations: any[] = []
60
+ let presented: { transform?: string; opacity?: string } | null = null
61
+ const el: any = {
62
+ style: { transform: '', opacity: '', transformOrigin: '' },
63
+ }
64
+
65
+ el.animate = (keyframes: any[], options: any) => {
66
+ let resolve!: (value: any) => void
67
+ let reject!: (reason: any) => void
68
+ const animation: any = {
69
+ keyframes,
70
+ options,
71
+ cancelled: false,
72
+ finished: new Promise((res, rej) => {
73
+ resolve = res
74
+ reject = rej
75
+ }),
76
+ present(transform: string, opacity?: string) {
77
+ presented = { transform, opacity }
78
+ },
79
+ finish() {
80
+ const last = keyframes[keyframes.length - 1] ?? {}
81
+ presented = {
82
+ transform: last.transform,
83
+ opacity: last.opacity === undefined ? undefined : String(last.opacity),
84
+ }
85
+
86
+ resolve(animation)
87
+ },
88
+ cancel() {
89
+ animation.cancelled = true
90
+ presented = null
91
+ reject(new Error('AbortError'))
92
+ },
93
+ }
94
+
95
+ animations.push(animation)
96
+ return animation
97
+ }
98
+
99
+ const computedFor = (node: any) => ({
100
+ transform: node === el ? (presented?.transform ?? styleToMatrix(el.style.transform)) : 'none',
101
+ opacity:
102
+ node === el
103
+ ? (presented?.opacity ?? (el.style.opacity === '' ? '1' : el.style.opacity))
104
+ : '1',
105
+ transformOrigin: node === el ? el.style.transformOrigin || '0px 0px' : '0px 0px',
106
+ })
107
+
108
+ vi.stubGlobal('getComputedStyle', computedFor)
109
+ return { el, animations }
110
+ }
111
+
112
+ const delegationCounters = () =>
113
+ ((globalThis as any).__xplatMotionDelegations ??= {
114
+ started: 0,
115
+ finished: 0,
116
+ cancelled: 0,
117
+ fallback: 0,
118
+ })
119
+
120
+ describe('web WAAPI delegation', () => {
121
+ beforeEach(() => {
122
+ delete (globalThis as any).__xplatMotionDelegations
123
+ })
124
+
125
+ afterEach(() => {
126
+ vi.unstubAllGlobals()
127
+ })
128
+
129
+ const request = (target: Target, transition: any = { duration: 0.5, ease: 'easeOut' }) => ({
130
+ target,
131
+ dest: { opacity: 1, x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0, ...target },
132
+ eff: { opacity: 1, x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0, ...target },
133
+ transition,
134
+ })
135
+
136
+ it('hands a bezier tween to element.animate and commits the destination on finish', async () => {
137
+ const { el, animations } = fakeWebElement()
138
+ const written: Target[] = []
139
+ const run = delegatedRun(
140
+ el,
141
+ request({ x: 100, opacity: 0.5 }, { duration: 0.5, delay: 0.1, ease: 'easeOut' }),
142
+ (target) => written.push({ ...target }),
143
+ )
144
+
145
+ expect(run).not.toBeNull()
146
+ expect(animations).toHaveLength(1)
147
+ const animation = animations[0]
148
+ expect(animation.options).toMatchObject({
149
+ duration: 500,
150
+ delay: 100,
151
+ easing: 'cubic-bezier(0, 0, 0.58, 1)',
152
+ fill: 'forwards',
153
+ })
154
+
155
+ expect(animation.keyframes[0].transform).toBe(
156
+ 'translateX(0px) translateY(0px) scaleX(1) scaleY(1) rotate(0deg)',
157
+ )
158
+
159
+ expect(animation.keyframes[1].transform).toBe(
160
+ 'translateX(100px) translateY(0px) scaleX(1) scaleY(1) rotate(0deg)',
161
+ )
162
+
163
+ expect(animation.keyframes[1].opacity).toBe(0.5)
164
+ expect(delegationCounters().started).toBe(1)
165
+
166
+ animation.present('matrix(1, 0, 0, 1, 40, 0)')
167
+ expect(run!.sample()).toMatchObject({ x: 40, scaleX: 1 })
168
+ animation.finish()
169
+ await expect(run!.finished).resolves.toBe('finished')
170
+ expect(written.at(-1)).toMatchObject({ x: 100, opacity: 0.5 })
171
+ expect(delegationCounters().finished).toBe(1)
172
+ })
173
+
174
+ it('samples the presentation before cancelling so the frozen values hold', async () => {
175
+ const { el, animations } = fakeWebElement()
176
+ const written: Target[] = []
177
+ const run = delegatedRun(el, request({ x: 100 }), (target) => written.push({ ...target }))
178
+
179
+ const animation = animations[0]
180
+ animation.present('matrix(0.5, 0, 0, 0.5, 42, 7)')
181
+ run!.cancel()
182
+ expect(animation.cancelled).toBe(true)
183
+ // cancel() reveals the base style — if the sample ran after it, the
184
+ // write would carry identity values instead of the mid-flight frame.
185
+ expect(written.at(-1)).toMatchObject({ x: 42, y: 7, scaleX: 0.5, scaleY: 0.5, rotate: 0 })
186
+ await expect(run!.finished).resolves.toBe('cancelled')
187
+ expect(delegationCounters().cancelled).toBe(1)
188
+ })
189
+
190
+ it('decomposes matrix3d and peels transform-origin out of x/y', () => {
191
+ const { el, animations } = fakeWebElement()
192
+ el.style.transformOrigin = '10px 20px'
193
+ const run = delegatedRun(el, request({ rotate: 90 }), () => {})
194
+
195
+ const animation = animations[0]
196
+ animation.present('matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 25, 5, 0, 1)')
197
+ expect(run!.sample()).toMatchObject({ x: 25, y: 5, scaleX: 1, scaleY: 1, rotate: 0 })
198
+ // rotate(90deg) about a (10, 20) origin resolves to this matrix.
199
+ animation.present('matrix(0, 1, -1, 0, 30, 10)')
200
+ const sampled = run!.sample()
201
+ expect(sampled.rotate).toBeCloseTo(90)
202
+ expect(sampled.x).toBeCloseTo(0)
203
+ expect(sampled.y).toBeCloseTo(0)
204
+ })
205
+
206
+ it('refuses non-bezier eases and elements without a WAAPI driver', () => {
207
+ const { el } = fakeWebElement()
208
+ const run = delegatedRun(el, request({ x: 100 }, { duration: 0.5, ease: 'backOut' }), () => {})
209
+
210
+ expect(run).toBeNull()
211
+ expect(delegatedRun({ style: {} }, request({ x: 50 }), () => {})).toBeNull()
212
+ expect(delegationCounters().fallback).toBe(2)
213
+ expect(delegationCounters().started).toBe(0)
214
+ })
215
+
216
+ it('delegates through the real web adapter and hands mid-flight state to the next run', async () => {
217
+ const { el, animations } = fakeWebElement()
218
+ const time = fakeClock()
219
+ const controller = new Controller(time.clock)
220
+ controller.attach(attachHost(el))
221
+ controller.seed({ x: 0 })
222
+
223
+ const doneFirst = controller.animate({ x: 100 }, { duration: 0.5, ease: 'linear' }, false)
224
+ expect(animations).toHaveLength(1)
225
+ animations[0].present('matrix(1, 0, 0, 1, 30, 0)')
226
+ time.advance(16)
227
+
228
+ const doneSecond = controller.animate({ x: -50 }, { duration: 0.5 }, false)
229
+ expect(animations[0].cancelled).toBe(true)
230
+ // Interruption sampled into the channel value and velocity.
231
+ expect(controller.values.get('x')?.get()).toBe(30)
232
+ expect(controller.values.get('x')?.getVelocity()).toBeGreaterThan(0)
233
+ // The replacement run's "from" keyframe continues the frozen frame.
234
+ expect(animations[1].keyframes[0].transform).toBe(
235
+ 'translateX(30px) translateY(0px) scaleX(1) scaleY(1) rotate(0deg)',
236
+ )
237
+
238
+ expect(await doneFirst).toBe('replaced')
239
+ animations[1].finish()
240
+ expect(await doneSecond).toBe('finished')
241
+ expect(controller.values.get('x')?.get()).toBe(-50)
242
+ expect(el.style.transform).toBe(
243
+ 'translateX(-50px) translateY(0px) scale(1) scaleX(1) scaleY(1) rotate(0deg)',
244
+ )
245
+ })
246
+
247
+ it.each([
248
+ ['a spring', { type: 'spring', stiffness: 100, damping: 10 }, false],
249
+ ['a non-bezier ease', { duration: 0.5, ease: 'backOut' as const }, false],
250
+ ['a repeating run', { duration: 0.5, repeat: 2 }, false],
251
+ ['per-key transitions', { default: { duration: 0.5 }, x: { duration: 1 } }, false],
252
+ ['an instant run', { duration: 0 }, false],
253
+ ['reduced motion', { duration: 0.5 }, true],
254
+ ])('stays on the JS engine for %s', async (_label, transition, reduced) => {
255
+ const { el, animations } = fakeWebElement()
256
+ const time = fakeClock()
257
+ const controller = new Controller(time.clock)
258
+ controller.attach(attachHost(el))
259
+ controller.seed({ x: 0 })
260
+
261
+ const done = controller.animate({ x: 100 }, transition as any, reduced)
262
+ expect(animations).toHaveLength(0)
263
+ for (let i = 0; i < 500; i++) {
264
+ time.advance(16)
265
+ }
266
+
267
+ expect(await done).toBe('finished')
268
+ expect(controller.values.get('x')?.get()).toBe(100)
269
+ })
270
+ })
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { attachPan } from './pan'
3
+ import {
4
+ handlers,
5
+ GestureHandlerStateEvent,
6
+ GestureHandlerTouchEvent,
7
+ GestureState,
8
+ } from '../test/gesturehandler'
9
+
10
+ describe('native handler drag surface', () => {
11
+ it('configures arbitration, normalizes velocity, and detaches handlers', () => {
12
+ const start = vi.fn(),
13
+ move = vi.fn(),
14
+ end = vi.fn()
15
+
16
+ const node = {}
17
+ const off = attachPan(node, 'x', { start, move, end })
18
+ const h = handlers.at(-1)
19
+ expect(h.node).toBe(node)
20
+ expect(h.options).toMatchObject({
21
+ activeOffsetXStart: -8,
22
+ activeOffsetXEnd: 8,
23
+ failOffsetYStart: -8,
24
+ failOffsetYEnd: 8,
25
+ })
26
+
27
+ h.emit(GestureHandlerStateEvent, { state: GestureState.BEGAN })
28
+ expect(start).not.toHaveBeenCalled()
29
+ h.emit(GestureHandlerStateEvent, {
30
+ state: GestureState.ACTIVE,
31
+ extraData: { translationX: 10, velocityX: 300 },
32
+ })
33
+
34
+ h.emit(GestureHandlerTouchEvent, {
35
+ state: GestureState.ACTIVE,
36
+ extraData: { translationX: 25, velocityX: 400 },
37
+ })
38
+
39
+ expect(move.mock.calls.at(-1)?.[1]).toMatchObject({
40
+ offset: { x: 25, y: 0 },
41
+ delta: { x: 15, y: 0 },
42
+ velocity: { x: 400, y: 0 },
43
+ })
44
+
45
+ h.emit(GestureHandlerStateEvent, {
46
+ state: GestureState.CANCELLED,
47
+ extraData: { translationX: 25 },
48
+ })
49
+
50
+ h.emit(GestureHandlerStateEvent, { state: GestureState.END })
51
+ expect(end).toHaveBeenCalledTimes(1)
52
+ expect(end.mock.calls[0][1].cancelled).toBe(true)
53
+ off()
54
+ expect(h.node).toBeUndefined()
55
+ h.emit(GestureHandlerTouchEvent, { state: GestureState.ACTIVE })
56
+ expect(move).toHaveBeenCalledTimes(2)
57
+ })
58
+
59
+ it('uses minDist for both axes and fails perpendicular movement for vertical drag', () => {
60
+ const callbacks = { start() {}, move() {}, end() {} }
61
+ const both = attachPan({}, true, callbacks)
62
+ expect(handlers.at(-1).options.minDist).toBe(8)
63
+ both()
64
+ const vertical = attachPan({}, 'y', callbacks)
65
+ expect(handlers.at(-1).options).toMatchObject({ activeOffsetYEnd: 8, failOffsetXStart: -8 })
66
+ vertical()
67
+ vi.stubGlobal('android', {})
68
+ const density = attachPan({}, true, callbacks)
69
+ expect(handlers.at(-1).options.minDist).toBe(16)
70
+ density()
71
+ const directional = attachPan({}, 'x', callbacks)
72
+ expect(handlers.at(-1).options).toMatchObject({
73
+ minDist: 1e9,
74
+ activeOffsetXEnd: 16,
75
+ failOffsetYEnd: 16,
76
+ })
77
+
78
+ directional()
79
+ vi.unstubAllGlobals()
80
+ })
81
+ })