@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.
- package/README.md +31 -2
- package/UPSTREAM.md +295 -27
- package/dist/native/index.js +980 -112
- package/dist/native/octane-client-build.json +1 -1
- package/dist/web/index.js +1104 -166
- package/dist/web/octane-client-build.json +1 -1
- package/package.json +25 -16
- package/src/clock.ts +13 -1
- package/src/components.mobile.test.tsrx +58 -1
- package/src/components.tsrx +185 -39
- package/src/components.web.test.tsrx +236 -3
- package/src/controller.ts +122 -10
- package/src/delegation.test.ts +269 -0
- package/src/delegation.ts +29 -0
- package/src/delegation.web.test.ts +270 -0
- package/src/drag.mobile.test.ts +81 -0
- package/src/drag.test.ts +167 -0
- package/src/drag.ts +157 -0
- package/src/driver.ts +249 -0
- package/src/driver.web.ts +180 -0
- package/src/engine.test.ts +99 -0
- package/src/engine.ts +140 -18
- package/src/hooks.tsrx +53 -2
- package/src/host-types.ts +44 -1
- package/src/host.ts +39 -2
- package/src/host.web.ts +51 -10
- package/src/index.ts +18 -4
- package/src/index.web.ts +18 -4
- package/src/pan.ts +92 -0
- package/src/pan.web.ts +116 -0
- package/src/presence-input.ts +11 -4
- package/src/presence-input.web.ts +13 -4
- package/src/presence-state.ts +27 -7
- package/src/presence.tsrx +6 -7
- package/src/reduced-motion.ts +23 -4
- package/src/types.ts +100 -11
- package/src/value.ts +18 -8
- package/src/variants.test.ts +197 -0
- package/src/variants.ts +226 -0
- package/types/engine.d.ts +8 -2
- package/types/index.d.ts +34 -6
- package/types/types.d.ts +94 -11
- 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
|
+
})
|