@octane-xplat/motion 0.0.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.motion +21 -0
- package/README.md +15 -0
- package/UPSTREAM.md +61 -0
- package/dist/native/index.js +1747 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/index.js +1826 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +59 -5
- package/src/clock-types.ts +6 -0
- package/src/clock.ts +7 -0
- package/src/clock.web.ts +6 -0
- package/src/components.mobile.test.tsrx +84 -0
- package/src/components.tsrx +142 -0
- package/src/components.web.test.tsrx +134 -0
- package/src/config.tsrx +29 -0
- package/src/controller.ts +98 -0
- package/src/engine.test.ts +126 -0
- package/src/engine.ts +159 -0
- package/src/hooks.tsrx +56 -0
- package/src/host-types.ts +6 -0
- package/src/host.ts +50 -0
- package/src/host.web.ts +34 -0
- package/src/index.ts +9 -0
- package/src/index.web.ts +9 -0
- package/src/presence-input.ts +26 -0
- package/src/presence-input.web.ts +17 -0
- package/src/presence-state.test.ts +64 -0
- package/src/presence-state.ts +76 -0
- package/src/presence.tsrx +52 -0
- package/src/presence.web.test.tsrx +122 -0
- package/src/reduced-motion.ts +37 -0
- package/src/reduced-motion.web.ts +13 -0
- package/src/types.ts +55 -0
- package/src/value.ts +167 -0
- package/types/clock-types.d.ts +6 -0
- package/types/engine.d.ts +11 -0
- package/types/index.d.ts +53 -0
- package/types/types.d.ts +51 -0
- package/types/value.d.ts +48 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { spring } from 'motion-dom'
|
|
3
|
+
import { runAnimation } from './engine'
|
|
4
|
+
import { MotionValue } from './value'
|
|
5
|
+
import { Controller } from './controller'
|
|
6
|
+
import type { Clock } from './clock-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
|
+
describe('portable playback', () => {
|
|
37
|
+
it('uses elapsed time and reaches the exact tween endpoint after suspension', async () => {
|
|
38
|
+
const time = fakeClock()
|
|
39
|
+
let value = 0
|
|
40
|
+
const controls = runAnimation(
|
|
41
|
+
time.clock,
|
|
42
|
+
0,
|
|
43
|
+
100,
|
|
44
|
+
{ duration: 1, delay: 0.1, ease: 'linear' },
|
|
45
|
+
(next) => {
|
|
46
|
+
value = next
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
time.advance(50)
|
|
51
|
+
expect(value).toBe(0)
|
|
52
|
+
time.advance(550)
|
|
53
|
+
expect(value).toBeCloseTo(50)
|
|
54
|
+
time.advance(10000)
|
|
55
|
+
expect(value).toBe(100)
|
|
56
|
+
expect(await controls.finished).toBe('finished')
|
|
57
|
+
expect(time.count()).toBe(0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('matches upstream spring samples at irregular frame intervals', () => {
|
|
61
|
+
const time = fakeClock()
|
|
62
|
+
let value = 0
|
|
63
|
+
const options = { stiffness: 180, damping: 16, mass: 2, velocity: 90 }
|
|
64
|
+
const reference = spring({ ...options, keyframes: [0, 100] })
|
|
65
|
+
runAnimation(time.clock, 0, 100, { ...options, type: 'spring' }, (next) => {
|
|
66
|
+
value = next
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
let elapsed = 0
|
|
70
|
+
for (const dt of [8, 16, 34, 5, 100, 10000]) {
|
|
71
|
+
elapsed += dt
|
|
72
|
+
time.advance(dt)
|
|
73
|
+
const expected = reference.next(elapsed)
|
|
74
|
+
expect(value).toBeCloseTo(expected.done ? 100 : expected.value)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
expect(value).toBe(100)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('settles cancellation once and never calls completion', async () => {
|
|
81
|
+
const time = fakeClock()
|
|
82
|
+
let completes = 0
|
|
83
|
+
const controls = runAnimation(
|
|
84
|
+
time.clock,
|
|
85
|
+
0,
|
|
86
|
+
1,
|
|
87
|
+
{},
|
|
88
|
+
() => {},
|
|
89
|
+
() => {
|
|
90
|
+
completes++
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
controls.stop()
|
|
95
|
+
controls.stop()
|
|
96
|
+
time.advance(1000)
|
|
97
|
+
expect(await controls.finished).toBe('cancelled')
|
|
98
|
+
expect(completes).toBe(0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('preserves velocity on retarget and jump cancels/reset velocity', async () => {
|
|
102
|
+
const time = fakeClock()
|
|
103
|
+
const value = new MotionValue(0, time.clock)
|
|
104
|
+
const first = value.animate(100, { type: 'spring' })
|
|
105
|
+
time.advance(16)
|
|
106
|
+
time.advance(16)
|
|
107
|
+
expect(value.getVelocity()).toBeGreaterThan(0)
|
|
108
|
+
const second = value.animate(0, { type: 'spring' })
|
|
109
|
+
expect(await first.finished).toBe('replaced')
|
|
110
|
+
value.jump(25)
|
|
111
|
+
expect(value.get()).toBe(25)
|
|
112
|
+
expect(value.getVelocity()).toBe(0)
|
|
113
|
+
expect(await second.finished).toBe('cancelled')
|
|
114
|
+
expect(time.count()).toBe(0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('cleans every channel on controller disposal', async () => {
|
|
118
|
+
const time = fakeClock()
|
|
119
|
+
const controller = new Controller(time.clock)
|
|
120
|
+
const result = controller.animate({ x: 40, opacity: 0 }, { duration: 1 }, false)
|
|
121
|
+
controller.destroy()
|
|
122
|
+
time.advance(1000)
|
|
123
|
+
expect(await result).toBe('replaced')
|
|
124
|
+
expect(time.count()).toBe(0)
|
|
125
|
+
})
|
|
126
|
+
})
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { keyframes, spring } from 'motion-dom'
|
|
2
|
+
import type { Clock } from './clock-types'
|
|
3
|
+
import type { Transition } from './types'
|
|
4
|
+
|
|
5
|
+
/** Terminal status; cancellation never masquerades as completion. */
|
|
6
|
+
export type AnimationResult = 'finished' | 'cancelled' | 'replaced'
|
|
7
|
+
/** A cancellable animation whose finished promise always settles. */
|
|
8
|
+
export interface AnimationControls {
|
|
9
|
+
finished: Promise<AnimationResult>
|
|
10
|
+
stop(reason?: Exclude<AnimationResult, 'finished'>): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function validateTransition(t: Transition): void {
|
|
14
|
+
if (t.type === 'spring' && (t.duration !== undefined || t.ease !== undefined)) {
|
|
15
|
+
throw new Error('motion: physical springs do not accept duration or ease')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const allowed = [
|
|
19
|
+
'type',
|
|
20
|
+
'duration',
|
|
21
|
+
'delay',
|
|
22
|
+
'ease',
|
|
23
|
+
'stiffness',
|
|
24
|
+
'damping',
|
|
25
|
+
'mass',
|
|
26
|
+
'velocity',
|
|
27
|
+
'restSpeed',
|
|
28
|
+
'restDelta',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
for (const key of Object.keys(t)) {
|
|
32
|
+
if (!allowed.includes(key)) {
|
|
33
|
+
throw new Error(`motion: unsupported transition ${key}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (t.type !== undefined && t.type !== 'tween' && t.type !== 'spring') {
|
|
38
|
+
throw new Error('motion: unsupported transition type')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const key of [
|
|
42
|
+
'duration',
|
|
43
|
+
'delay',
|
|
44
|
+
'stiffness',
|
|
45
|
+
'damping',
|
|
46
|
+
'mass',
|
|
47
|
+
'velocity',
|
|
48
|
+
'restSpeed',
|
|
49
|
+
'restDelta',
|
|
50
|
+
] as const) {
|
|
51
|
+
const value = t[key]
|
|
52
|
+
if (value !== undefined && (!Number.isFinite(value) || (key !== 'velocity' && value < 0))) {
|
|
53
|
+
throw new Error(`motion: invalid ${key}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const key of ['mass', 'stiffness', 'damping', 'restSpeed', 'restDelta'] as const) {
|
|
58
|
+
if (t[key] === 0) {
|
|
59
|
+
throw new Error(`motion: ${key} must be positive`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (t.ease !== undefined) {
|
|
64
|
+
if (Array.isArray(t.ease)) {
|
|
65
|
+
if (
|
|
66
|
+
t.ease.length !== 4 ||
|
|
67
|
+
!t.ease.every(Number.isFinite) ||
|
|
68
|
+
t.ease[0] < 0 ||
|
|
69
|
+
t.ease[0] > 1 ||
|
|
70
|
+
t.ease[2] < 0 ||
|
|
71
|
+
t.ease[2] > 1
|
|
72
|
+
) {
|
|
73
|
+
throw new Error('motion: invalid cubic Bezier')
|
|
74
|
+
}
|
|
75
|
+
} else if (!['linear', 'easeIn', 'easeOut', 'easeInOut'].includes(t.ease)) {
|
|
76
|
+
throw new Error('motion: unsupported easing')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pure upstream generators share trajectories across platforms; host scheduling
|
|
82
|
+
// stays outside Motion's browser-global frame loop.
|
|
83
|
+
export function runAnimation(
|
|
84
|
+
clock: Clock,
|
|
85
|
+
from: number,
|
|
86
|
+
to: number,
|
|
87
|
+
transition: Transition,
|
|
88
|
+
update: (value: number) => void,
|
|
89
|
+
complete?: () => void,
|
|
90
|
+
): AnimationControls {
|
|
91
|
+
validateTransition(transition)
|
|
92
|
+
let frame = 0
|
|
93
|
+
let settled = false
|
|
94
|
+
let resolve!: (result: AnimationResult) => void
|
|
95
|
+
const finished = new Promise<AnimationResult>((done) => {
|
|
96
|
+
resolve = done
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const settle = (result: AnimationResult) => {
|
|
100
|
+
if (settled) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
settled = true
|
|
105
|
+
clock.cancel(frame)
|
|
106
|
+
resolve(result)
|
|
107
|
+
if (result === 'finished') {
|
|
108
|
+
complete?.()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const duration = transition.duration ?? 0.3
|
|
113
|
+
const generator =
|
|
114
|
+
transition.type === 'spring'
|
|
115
|
+
? spring({
|
|
116
|
+
keyframes: [from, to],
|
|
117
|
+
stiffness: transition.stiffness ?? 100,
|
|
118
|
+
damping: transition.damping ?? 10,
|
|
119
|
+
mass: transition.mass ?? 1,
|
|
120
|
+
velocity: transition.velocity ?? 0,
|
|
121
|
+
restSpeed: transition.restSpeed,
|
|
122
|
+
restDelta: transition.restDelta,
|
|
123
|
+
})
|
|
124
|
+
: keyframes({
|
|
125
|
+
keyframes: [from, to],
|
|
126
|
+
duration: duration * 1000,
|
|
127
|
+
ease: transition.ease ?? 'easeInOut',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const start = clock.now() + (transition.delay ?? 0) * 1000
|
|
131
|
+
const tick = () => {
|
|
132
|
+
if (settled) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const elapsed = Math.max(0, clock.now() - start)
|
|
137
|
+
if (clock.now() >= start) {
|
|
138
|
+
const state = generator.next(elapsed)
|
|
139
|
+
update(state.done ? to : state.value)
|
|
140
|
+
if (state.done) {
|
|
141
|
+
settle('finished')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!settled) {
|
|
147
|
+
frame = clock.request(tick)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (transition.type !== 'spring' && duration === 0 && !transition.delay) {
|
|
152
|
+
update(to)
|
|
153
|
+
settle('finished')
|
|
154
|
+
} else {
|
|
155
|
+
frame = clock.request(tick)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { finished, stop: (reason = 'cancelled') => settle(reason) }
|
|
159
|
+
}
|
package/src/hooks.tsrx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useRef, useLayoutEffect } from 'octane'
|
|
2
|
+
import { interpolate } from 'motion-dom'
|
|
3
|
+
import { clock } from './clock'
|
|
4
|
+
import { MotionValue, type MotionValueEvents } from './value'
|
|
5
|
+
import type { Transition } from './types'
|
|
6
|
+
|
|
7
|
+
/** Create an owned numeric value that writes to bound hosts without rendering. */
|
|
8
|
+
export function useMotionValue(initial: number): MotionValue {
|
|
9
|
+
const ref = useRef<MotionValue | null>(null)
|
|
10
|
+
if (!ref.current) ref.current = new MotionValue(initial, clock)
|
|
11
|
+
const value = ref.current
|
|
12
|
+
useLayoutEffect(() => () => value.destroy(), [])
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Subscribe to a value event; subscriptions follow value/callback replacement. */
|
|
17
|
+
export function useMotionValueEvent<K extends keyof MotionValueEvents>(value: MotionValue, event: K, callback: MotionValueEvents[K]): void {
|
|
18
|
+
useLayoutEffect(() => value.on(event, callback), [value, event, callback])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Derive a numeric value using a range map, unary transform, or multi-input transform. */
|
|
22
|
+
export function useTransform(source: MotionValue, input: number[], output: number[], options?: { clamp?: boolean }): MotionValue
|
|
23
|
+
export function useTransform(source: MotionValue, transform: (value: number) => number): MotionValue
|
|
24
|
+
export function useTransform(source: MotionValue[], transform: (values: number[]) => number): MotionValue
|
|
25
|
+
export function useTransform(source: MotionValue | MotionValue[], input: number[] | ((value: any) => number), output?: number[], options?: { clamp?: boolean }): MotionValue {
|
|
26
|
+
const sources = Array.isArray(source) ? source : [source]
|
|
27
|
+
if (Array.isArray(input) && (!output || input.length !== output.length || input.length < 2)) throw new Error('motion: transform ranges must have matching lengths >= 2')
|
|
28
|
+
const transform: (value: any) => number = typeof input === 'function' ? input : interpolate(input, output!, options)
|
|
29
|
+
const read = () => transform(Array.isArray(source) ? sources.map((value) => value.get()) : source.get())
|
|
30
|
+
const value = useMotionValue(read())
|
|
31
|
+
// A source array may be freshly allocated; compare its members, not its identity.
|
|
32
|
+
const previous = useRef<MotionValue[]>(sources)
|
|
33
|
+
if (previous.current.length !== sources.length || previous.current.some((value, i) => value !== sources[i])) previous.current = sources
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const update = () => value.set(read())
|
|
36
|
+
const off = sources.map((source) => source.on('change', update))
|
|
37
|
+
update()
|
|
38
|
+
return () => off.forEach((unsubscribe) => unsubscribe())
|
|
39
|
+
}, [previous.current, input, output, options?.clamp])
|
|
40
|
+
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A spring value: set animates; jump snaps. A source value is followed directly. */
|
|
45
|
+
export function useSpring(source: number | MotionValue, options: Omit<Transition, 'type' | 'duration' | 'ease' | 'delay'> = {}): MotionValue {
|
|
46
|
+
const value = useMotionValue(source instanceof MotionValue ? source.get() : source)
|
|
47
|
+
useLayoutEffect(() => {
|
|
48
|
+
const follow = (target: number) => { value.animate(target, { ...options, type: 'spring' }) }
|
|
49
|
+
const detach = value.attach(follow)
|
|
50
|
+
const unsubscribe = source instanceof MotionValue ? source.on('change', follow) : undefined
|
|
51
|
+
if (source instanceof MotionValue && source.get() !== value.get()) follow(source.get())
|
|
52
|
+
return () => { unsubscribe?.(); detach() }
|
|
53
|
+
}, [source instanceof MotionValue ? source : null, JSON.stringify(options)])
|
|
54
|
+
|
|
55
|
+
return value
|
|
56
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { HostAdapter } from './host-types'
|
|
2
|
+
// The retained NativeScript View exposes direct transform channels in DIP.
|
|
3
|
+
export function attachHost(node: any): HostAdapter {
|
|
4
|
+
const original = {
|
|
5
|
+
opacity: node.opacity ?? 1,
|
|
6
|
+
x: node.translateX ?? 0,
|
|
7
|
+
y: node.translateY ?? 0,
|
|
8
|
+
scaleX: node.scaleX ?? 1,
|
|
9
|
+
scaleY: node.scaleY ?? 1,
|
|
10
|
+
rotate: node.rotate ?? 0,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (
|
|
14
|
+
original.x ||
|
|
15
|
+
original.y ||
|
|
16
|
+
original.rotate ||
|
|
17
|
+
original.scaleX !== 1 ||
|
|
18
|
+
original.scaleY !== 1
|
|
19
|
+
) {
|
|
20
|
+
throw new Error('motion: put existing transforms on an outer container')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const write = (values: any) => {
|
|
24
|
+
if (values.opacity !== undefined) {
|
|
25
|
+
node.opacity = Math.min(1, Math.max(0, values.opacity))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (values.x !== undefined) {
|
|
29
|
+
node.translateX = values.x
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (values.y !== undefined) {
|
|
33
|
+
node.translateY = values.y
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (values.rotate !== undefined) {
|
|
37
|
+
node.rotate = values.rotate
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (values.scale !== undefined || values.scaleX !== undefined) {
|
|
41
|
+
node.scaleX = (values.scale ?? 1) * (values.scaleX ?? 1)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (values.scale !== undefined || values.scaleY !== undefined) {
|
|
45
|
+
node.scaleY = (values.scale ?? 1) * (values.scaleY ?? 1)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { read: () => ({ ...original, scale: 1 }), write, restore: () => write(original) }
|
|
50
|
+
}
|
package/src/host.web.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { HostAdapter } from './host-types'
|
|
2
|
+
export function attachHost(node: HTMLElement): HostAdapter {
|
|
3
|
+
const transform = node.style.transform
|
|
4
|
+
const opacity = node.style.opacity
|
|
5
|
+
const computed = getComputedStyle(node)
|
|
6
|
+
if (computed.transform && computed.transform !== 'none') {
|
|
7
|
+
throw new Error('motion: put existing CSS transforms on an outer container')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
read: () => ({
|
|
12
|
+
opacity: Number(computed.opacity || 1),
|
|
13
|
+
x: 0,
|
|
14
|
+
y: 0,
|
|
15
|
+
scale: 1,
|
|
16
|
+
scaleX: 1,
|
|
17
|
+
scaleY: 1,
|
|
18
|
+
rotate: 0,
|
|
19
|
+
}),
|
|
20
|
+
write(values) {
|
|
21
|
+
if (values.opacity !== undefined) {
|
|
22
|
+
node.style.opacity = String(Math.min(1, Math.max(0, values.opacity)))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (Object.keys(values).some((key) => key !== 'opacity')) {
|
|
26
|
+
node.style.transform = `translateX(${values.x ?? 0}px) translateY(${values.y ?? 0}px) scale(${values.scale ?? 1}) scaleX(${values.scaleX ?? 1}) scaleY(${values.scaleY ?? 1}) rotate(${values.rotate ?? 0}deg)`
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
restore() {
|
|
30
|
+
node.style.transform = transform
|
|
31
|
+
node.style.opacity = opacity
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { MotionView, MotionRow, MotionPressable } from './components.tsrx'
|
|
2
|
+
/** Bounded motion hosts for shared xplat UI primitives. */
|
|
3
|
+
export const motion = { View: MotionView, Row: MotionRow, Pressable: MotionPressable }
|
|
4
|
+
export { MotionConfig, useReducedMotion } from './config.tsrx'
|
|
5
|
+
export { useMotionValue, useTransform, useSpring, useMotionValueEvent } from './hooks.tsrx'
|
|
6
|
+
export type { MotionValue, MotionValueEvents } from './value'
|
|
7
|
+
export type { AnimationControls, AnimationResult } from './engine'
|
|
8
|
+
export type * from './types'
|
|
9
|
+
export { Presence } from './presence.tsrx'
|
package/src/index.web.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { MotionView, MotionRow, MotionPressable } from './components.tsrx'
|
|
2
|
+
/** Bounded motion hosts for shared xplat UI primitives. */
|
|
3
|
+
export const motion = { View: MotionView, Row: MotionRow, Pressable: MotionPressable }
|
|
4
|
+
export { MotionConfig, useReducedMotion } from './config.tsrx'
|
|
5
|
+
export { useMotionValue, useTransform, useSpring, useMotionValueEvent } from './hooks.tsrx'
|
|
6
|
+
export type { MotionValue, MotionValueEvents } from './value'
|
|
7
|
+
export type { AnimationControls, AnimationResult } from './engine'
|
|
8
|
+
export type * from './types'
|
|
9
|
+
export { Presence } from './presence.tsrx'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function blockInput(node: any): () => void {
|
|
2
|
+
const enabled = node.isUserInteractionEnabled
|
|
3
|
+
const iosHidden = node.ios?.accessibilityElementsHidden
|
|
4
|
+
const androidImportance = node.android?.getImportantForAccessibility?.()
|
|
5
|
+
node.ios?.endEditing?.(true)
|
|
6
|
+
const focused = node.android?.findFocus?.()
|
|
7
|
+
if (focused) {
|
|
8
|
+
const platform = globalThis as any
|
|
9
|
+
const context = focused.getContext()
|
|
10
|
+
context
|
|
11
|
+
.getSystemService(platform.android.content.Context.INPUT_METHOD_SERVICE)
|
|
12
|
+
?.hideSoftInputFromWindow(focused.getWindowToken(), 0)
|
|
13
|
+
|
|
14
|
+
focused.clearFocus()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
node.isUserInteractionEnabled = false
|
|
18
|
+
if (node.ios) {node.ios.accessibilityElementsHidden = true}
|
|
19
|
+
node.android?.setImportantForAccessibility?.(4) // IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
|
20
|
+
return () => {
|
|
21
|
+
node.isUserInteractionEnabled = enabled
|
|
22
|
+
if (node.ios) {node.ios.accessibilityElementsHidden = iosHidden}
|
|
23
|
+
if (androidImportance !== undefined)
|
|
24
|
+
{node.android?.setImportantForAccessibility?.(androidImportance)}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function blockInput(node: HTMLElement): () => void {
|
|
2
|
+
const inert = node.hasAttribute('inert')
|
|
3
|
+
const hidden = node.getAttribute('aria-hidden')
|
|
4
|
+
const pointerEvents = node.style.pointerEvents
|
|
5
|
+
const focused = node.ownerDocument.activeElement as HTMLElement | null
|
|
6
|
+
if (focused && node.contains(focused)) {focused.blur()}
|
|
7
|
+
node.setAttribute('inert', '')
|
|
8
|
+
node.setAttribute('aria-hidden', 'true')
|
|
9
|
+
node.style.pointerEvents = 'none'
|
|
10
|
+
return () => {
|
|
11
|
+
if (!inert) {node.removeAttribute('inert')}
|
|
12
|
+
if (hidden === null) {node.removeAttribute('aria-hidden')}
|
|
13
|
+
else {node.setAttribute('aria-hidden', hidden)}
|
|
14
|
+
|
|
15
|
+
node.style.pointerEvents = pointerEvents
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { expect, it, vi } from 'vitest'
|
|
2
|
+
import { PresenceState } from './presence-state'
|
|
3
|
+
import type { AnimationResult } from './engine'
|
|
4
|
+
const deferred = () => {
|
|
5
|
+
let resolve!: (result: AnimationResult) => void
|
|
6
|
+
const promise = new Promise<AnimationResult>((done) => {
|
|
7
|
+
resolve = done
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
return { promise, resolve }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const drain = async () => {
|
|
14
|
+
await Promise.resolve()
|
|
15
|
+
await Promise.resolve()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
it('waits for every registered child and ignores replaced exit completions', async () => {
|
|
19
|
+
const done = vi.fn(),
|
|
20
|
+
state = new PresenceState(true, done)
|
|
21
|
+
|
|
22
|
+
const a = deferred(),
|
|
23
|
+
b = deferred(),
|
|
24
|
+
replacement = deferred()
|
|
25
|
+
|
|
26
|
+
const memberA = { exit: vi.fn(() => a.promise), enter: vi.fn() }
|
|
27
|
+
state.register(memberA)
|
|
28
|
+
state.register({ exit: () => b.promise, enter() {} })
|
|
29
|
+
state.setPresent(false)
|
|
30
|
+
memberA.exit.mockReturnValue(replacement.promise)
|
|
31
|
+
state.restart(memberA)
|
|
32
|
+
a.resolve('finished')
|
|
33
|
+
b.resolve('finished')
|
|
34
|
+
await drain()
|
|
35
|
+
expect(done).not.toHaveBeenCalled()
|
|
36
|
+
replacement.resolve('finished')
|
|
37
|
+
await drain()
|
|
38
|
+
expect(done).toHaveBeenCalledTimes(1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('unregisters removed children and completes an empty boundary once', async () => {
|
|
42
|
+
const done = vi.fn(),
|
|
43
|
+
state = new PresenceState(true, done)
|
|
44
|
+
|
|
45
|
+
const pending = deferred()
|
|
46
|
+
const off = state.register({ exit: () => pending.promise, enter() {} })
|
|
47
|
+
state.setPresent(false)
|
|
48
|
+
off()
|
|
49
|
+
await drain()
|
|
50
|
+
expect(done).toHaveBeenCalledTimes(1)
|
|
51
|
+
pending.resolve('finished')
|
|
52
|
+
await drain()
|
|
53
|
+
expect(done).toHaveBeenCalledTimes(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('a destroyed boundary never invokes completion', async () => {
|
|
57
|
+
const done = vi.fn(),
|
|
58
|
+
state = new PresenceState(true, done)
|
|
59
|
+
|
|
60
|
+
state.setPresent(false)
|
|
61
|
+
state.destroy()
|
|
62
|
+
await drain()
|
|
63
|
+
expect(done).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { AnimationResult } from './engine'
|
|
2
|
+
|
|
3
|
+
export interface PresenceMember {
|
|
4
|
+
exit(): Promise<AnimationResult>
|
|
5
|
+
enter(): void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Membership is explicit: replacing an exit invalidates its completion token.
|
|
9
|
+
// Microtask completion lets every child register before an empty/instant exit ends.
|
|
10
|
+
export class PresenceState {
|
|
11
|
+
private members = new Set<PresenceMember>()
|
|
12
|
+
private pending = new Map<PresenceMember, number>()
|
|
13
|
+
private sequence = 0
|
|
14
|
+
private generation = 0
|
|
15
|
+
private disposed = false
|
|
16
|
+
private removed = false
|
|
17
|
+
constructor(
|
|
18
|
+
public present: boolean,
|
|
19
|
+
private complete: () => void,
|
|
20
|
+
) {}
|
|
21
|
+
register(member: PresenceMember): () => void {
|
|
22
|
+
this.members.add(member)
|
|
23
|
+
if (!this.present) {this.restart(member)}
|
|
24
|
+
return () => {
|
|
25
|
+
this.members.delete(member)
|
|
26
|
+
this.pending.delete(member)
|
|
27
|
+
this.check()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
setPresent(present: boolean) {
|
|
31
|
+
if (this.disposed || present === this.present) {return}
|
|
32
|
+
this.present = present
|
|
33
|
+
++this.generation
|
|
34
|
+
this.removed = false
|
|
35
|
+
this.pending.clear()
|
|
36
|
+
if (present) {
|
|
37
|
+
for (const member of this.members) {member.enter()}
|
|
38
|
+
} else {
|
|
39
|
+
for (const member of this.members) {this.restart(member)}
|
|
40
|
+
this.check()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
restart(member: PresenceMember) {
|
|
44
|
+
if (this.disposed || this.present || !this.members.has(member)) {return}
|
|
45
|
+
const token = ++this.sequence
|
|
46
|
+
this.pending.set(member, token)
|
|
47
|
+
void member.exit().then((result) => {
|
|
48
|
+
if (this.disposed || this.present || this.pending.get(member) !== token) {return}
|
|
49
|
+
// Replacement is owned by the next restart. Cancellation cannot remove a live subtree.
|
|
50
|
+
if (result !== 'finished') {return}
|
|
51
|
+
this.pending.delete(member)
|
|
52
|
+
this.check()
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
private check() {
|
|
56
|
+
const generation = this.generation
|
|
57
|
+
queueMicrotask(() => {
|
|
58
|
+
if (
|
|
59
|
+
!this.disposed &&
|
|
60
|
+
!this.present &&
|
|
61
|
+
!this.removed &&
|
|
62
|
+
!this.pending.size &&
|
|
63
|
+
generation === this.generation
|
|
64
|
+
) {
|
|
65
|
+
this.removed = true
|
|
66
|
+
this.complete()
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
destroy() {
|
|
71
|
+
this.disposed = true
|
|
72
|
+
++this.generation
|
|
73
|
+
this.members.clear()
|
|
74
|
+
this.pending.clear()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createContext, useRef, useLayoutEffect, useState } from 'octane'
|
|
2
|
+
import { View } from '@octane-xplat/ui'
|
|
3
|
+
import type { PresenceProps } from './types'
|
|
4
|
+
import { PresenceState } from './presence-state'
|
|
5
|
+
import { blockInput } from './presence-input'
|
|
6
|
+
|
|
7
|
+
export const PresenceContext = createContext<PresenceState | null>(null)
|
|
8
|
+
|
|
9
|
+
/** Retain live children until their exits finish. Keep this boundary mounted. */
|
|
10
|
+
export function Presence(props: PresenceProps) @{
|
|
11
|
+
const [retained, setRetained] = useState(props.present)
|
|
12
|
+
const latest = useRef(props)
|
|
13
|
+
latest.current = props
|
|
14
|
+
const ref = useRef<PresenceState | null>(null)
|
|
15
|
+
if (!ref.current) ref.current = new PresenceState(props.present, () => {
|
|
16
|
+
setRetained(false)
|
|
17
|
+
latest.current.onExitComplete?.()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const state = ref.current
|
|
21
|
+
const host = useRef<any>(null)
|
|
22
|
+
const restoreInput = useRef<(() => void) | null>(null)
|
|
23
|
+
const syncInput = () => {
|
|
24
|
+
if (!props.present && host.current && !restoreInput.current) restoreInput.current = blockInput(host.current)
|
|
25
|
+
if (props.present && restoreInput.current) { restoreInput.current(); restoreInput.current = null }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
if (props.present) setRetained(true)
|
|
30
|
+
syncInput()
|
|
31
|
+
state.setPresent(props.present)
|
|
32
|
+
}, [props.present])
|
|
33
|
+
|
|
34
|
+
useLayoutEffect(() => () => {
|
|
35
|
+
state.destroy()
|
|
36
|
+
restoreInput.current?.()
|
|
37
|
+
restoreInput.current = null
|
|
38
|
+
host.current = null
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
const { present, onExitComplete, children, bind, ...viewProps } = props
|
|
42
|
+
|
|
43
|
+
@if (props.present || retained) {
|
|
44
|
+
<View {...viewProps} bind={(node: any) => {
|
|
45
|
+
if (host.current !== node) { restoreInput.current?.(); restoreInput.current = null; host.current = node }
|
|
46
|
+
syncInput()
|
|
47
|
+
bind?.(node)
|
|
48
|
+
}}>
|
|
49
|
+
<PresenceContext value={state}>{children}</PresenceContext>
|
|
50
|
+
</View>
|
|
51
|
+
}
|
|
52
|
+
}
|