@octane-xplat/motion 0.9.0 → 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 +284 -16
- 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 +8 -3
- package/src/clock.ts +13 -1
- package/src/components.mobile.test.tsrx +58 -0
- package/src/components.tsrx +185 -39
- package/src/components.web.test.tsrx +234 -1
- 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 +86 -0
- package/src/engine.ts +137 -15
- 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.tsrx +6 -7
- package/src/reduced-motion.ts +23 -4
- package/src/types.ts +99 -10
- package/src/value.ts +15 -5
- package/src/variants.test.ts +197 -0
- package/src/variants.ts +226 -0
- package/types/engine.d.ts +7 -1
- package/types/index.d.ts +22 -1
- package/types/types.d.ts +93 -10
- package/types/value.d.ts +4 -1
package/src/pan.web.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { PanCallbacks } from './host-types'
|
|
2
|
+
import type { DragInfo } from './types'
|
|
3
|
+
|
|
4
|
+
export function attachPan(node: HTMLElement, axis: true | 'x' | 'y', callbacks: PanCallbacks) {
|
|
5
|
+
const touchAction = node.style.touchAction
|
|
6
|
+
node.style.touchAction = axis === 'x' ? 'pan-y' : axis === 'y' ? 'pan-x' : 'none'
|
|
7
|
+
let pointer: number | undefined
|
|
8
|
+
let active = false
|
|
9
|
+
let start = { x: 0, y: 0 }
|
|
10
|
+
let last = start
|
|
11
|
+
let time = 0
|
|
12
|
+
let velocity = { x: 0, y: 0 }
|
|
13
|
+
let velocityTime = 0
|
|
14
|
+
const sample = (event: PointerEvent, cancelled = false): DragInfo => {
|
|
15
|
+
const point = cancelled ? last : { x: event.clientX, y: event.clientY }
|
|
16
|
+
const delta = { x: point.x - last.x, y: point.y - last.y }
|
|
17
|
+
const elapsed = event.timeStamp - time
|
|
18
|
+
if (elapsed > 0 && (delta.x || delta.y)) {
|
|
19
|
+
velocity = { x: (delta.x * 1000) / elapsed, y: (delta.y * 1000) / elapsed }
|
|
20
|
+
velocityTime = event.timeStamp
|
|
21
|
+
} else if (event.timeStamp - velocityTime > 100) {
|
|
22
|
+
velocity = { x: 0, y: 0 }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
last = point
|
|
26
|
+
time = event.timeStamp
|
|
27
|
+
return {
|
|
28
|
+
point,
|
|
29
|
+
delta,
|
|
30
|
+
offset: { x: point.x - start.x, y: point.y - start.y },
|
|
31
|
+
velocity,
|
|
32
|
+
cancelled,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const down = (event: PointerEvent) => {
|
|
37
|
+
if (pointer !== undefined || event.button !== 0 || event.isPrimary === false) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pointer = event.pointerId
|
|
42
|
+
start = last = { x: event.clientX, y: event.clientY }
|
|
43
|
+
time = velocityTime = event.timeStamp
|
|
44
|
+
velocity = { x: 0, y: 0 }
|
|
45
|
+
try {
|
|
46
|
+
node.setPointerCapture?.(event.pointerId)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Synthetic pointer dispatch has no active browser pointer.
|
|
49
|
+
if (event.isTrusted || (error as Error).name !== 'NotFoundError') {
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const move = (event: PointerEvent) => {
|
|
56
|
+
if (event.pointerId !== pointer) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const info = sample(event)
|
|
61
|
+
if (!active) {
|
|
62
|
+
const primary = axis === 'y' ? info.offset.y : info.offset.x
|
|
63
|
+
const other = axis === 'y' ? info.offset.x : info.offset.y
|
|
64
|
+
if (axis !== true && Math.abs(other) > 8 && Math.abs(primary) <= 8) {
|
|
65
|
+
finish(event, true)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if ((axis === true ? Math.hypot(info.offset.x, info.offset.y) : Math.abs(primary)) <= 8) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
active = true
|
|
74
|
+
callbacks.start(event, info)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
callbacks.move(event, info)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const finish = (event: PointerEvent, cancelled: boolean) => {
|
|
81
|
+
if (pointer === undefined || event.pointerId !== pointer) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const id = pointer
|
|
86
|
+
pointer = undefined
|
|
87
|
+
if (active) {
|
|
88
|
+
active = false
|
|
89
|
+
callbacks.end(event, sample(event, cancelled))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (node.hasPointerCapture?.(id)) {
|
|
93
|
+
node.releasePointerCapture(id)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const up = (event: PointerEvent) => finish(event, false)
|
|
98
|
+
const cancel = (event: PointerEvent) => finish(event, true)
|
|
99
|
+
node.addEventListener('pointerdown', down)
|
|
100
|
+
node.addEventListener('pointermove', move)
|
|
101
|
+
node.addEventListener('pointerup', up)
|
|
102
|
+
node.addEventListener('pointercancel', cancel)
|
|
103
|
+
node.addEventListener('lostpointercapture', cancel)
|
|
104
|
+
return () => {
|
|
105
|
+
node.removeEventListener('pointerdown', down)
|
|
106
|
+
node.removeEventListener('pointermove', move)
|
|
107
|
+
node.removeEventListener('pointerup', up)
|
|
108
|
+
node.removeEventListener('pointercancel', cancel)
|
|
109
|
+
node.removeEventListener('lostpointercapture', cancel)
|
|
110
|
+
if (pointer !== undefined && node.hasPointerCapture?.(pointer)) {
|
|
111
|
+
node.releasePointerCapture(pointer)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
node.style.touchAction = touchAction
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/presence.tsrx
CHANGED
|
@@ -11,13 +11,13 @@ export function Presence(props: PresenceProps) @{
|
|
|
11
11
|
const [retained, setRetained] = useState(props.present)
|
|
12
12
|
const latest = useRef(props)
|
|
13
13
|
latest.current = props
|
|
14
|
-
const
|
|
15
|
-
if (!
|
|
14
|
+
const stateRef = useRef<PresenceState | null>(null)
|
|
15
|
+
if (!stateRef.current) stateRef.current = new PresenceState(props.present, () => {
|
|
16
16
|
setRetained(false)
|
|
17
17
|
latest.current.onExitComplete?.()
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
const state =
|
|
20
|
+
const state = stateRef.current
|
|
21
21
|
const host = useRef<any>(null)
|
|
22
22
|
const restoreInput = useRef<(() => void) | null>(null)
|
|
23
23
|
const syncInput = () => {
|
|
@@ -38,14 +38,13 @@ export function Presence(props: PresenceProps) @{
|
|
|
38
38
|
host.current = null
|
|
39
39
|
}, [])
|
|
40
40
|
|
|
41
|
-
const { present, onExitComplete, children,
|
|
41
|
+
const { present, onExitComplete, children, ref, ...viewProps } = props
|
|
42
42
|
|
|
43
43
|
@if (props.present || retained) {
|
|
44
|
-
<View {...viewProps}
|
|
44
|
+
<View {...viewProps} ref={[(node: any) => {
|
|
45
45
|
if (host.current !== node) { restoreInput.current?.(); restoreInput.current = null; host.current = node }
|
|
46
46
|
syncInput()
|
|
47
|
-
|
|
48
|
-
}}>
|
|
47
|
+
}, ref]}>
|
|
49
48
|
<PresenceContext value={state}>{children}</PresenceContext>
|
|
50
49
|
</View>
|
|
51
50
|
}
|
package/src/reduced-motion.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Application, Utils } from '@nativescript/core'
|
|
1
|
+
import { Application, Device, Utils } from '@nativescript/core'
|
|
2
2
|
|
|
3
3
|
export function readReducedMotion(): boolean {
|
|
4
4
|
const platform = globalThis as any
|
|
@@ -6,7 +6,19 @@ export function readReducedMotion(): boolean {
|
|
|
6
6
|
return Boolean(platform.UIAccessibilityIsReduceMotionEnabled?.())
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
if (!platform.android) {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (
|
|
14
|
+
Number(Device.sdkVersion) >= 26 &&
|
|
15
|
+
platform.android.animation?.ValueAnimator?.areAnimatorsEnabled?.() === false
|
|
16
|
+
) {
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// `Utils.android` is a {} stub in the pinned Windows core build.
|
|
21
|
+
const context = (Utils.android as any).getApplicationContext()
|
|
10
22
|
return (
|
|
11
23
|
platform.android.provider.Settings.Global.getFloat(
|
|
12
24
|
context.getContentResolver(),
|
|
@@ -16,9 +28,16 @@ export function readReducedMotion(): boolean {
|
|
|
16
28
|
)
|
|
17
29
|
}
|
|
18
30
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
31
|
+
// iOS posts a notification on preference change; Android has no event, so it
|
|
32
|
+
// polls only while subscribed (also catches developer-setting changes) and
|
|
33
|
+
// refreshes on resume.
|
|
21
34
|
export function observeReducedMotion(notify: () => void): () => void {
|
|
35
|
+
if (Application.ios) {
|
|
36
|
+
const name = (globalThis as any).UIAccessibilityReduceMotionStatusDidChangeNotification
|
|
37
|
+
const observer = Application.ios.addNotificationObserver(name, notify)
|
|
38
|
+
return () => Application.ios.removeNotificationObserver(observer, name)
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
let previous = readReducedMotion()
|
|
23
42
|
const check = () => {
|
|
24
43
|
const next = readReducedMotion()
|
package/src/types.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { View,
|
|
1
|
+
import type { View, HStack, Pressable } from '@octane-xplat/ui'
|
|
2
2
|
import type { MotionValue } from './value.js'
|
|
3
3
|
|
|
4
4
|
type ViewProps = Parameters<typeof View>[0]
|
|
5
|
-
type
|
|
5
|
+
type HStackProps = Parameters<typeof HStack>[0]
|
|
6
6
|
type PressableProps = Parameters<typeof Pressable>[0]
|
|
7
7
|
|
|
8
8
|
/** Supported channels. Translation is CSS pixels/DIP; rotation is degrees. */
|
|
@@ -10,13 +10,43 @@ export type MotionKey = 'opacity' | 'x' | 'y' | 'scale' | 'scaleX' | 'scaleY' |
|
|
|
10
10
|
/** Absolute numeric destinations; omitted channels retain their current values. */
|
|
11
11
|
export type Target = Partial<Record<MotionKey, number>>
|
|
12
12
|
/** Tween easing, including a CSS-compatible cubic Bezier tuple. */
|
|
13
|
-
export type Ease =
|
|
13
|
+
export type Ease =
|
|
14
|
+
| 'linear'
|
|
15
|
+
| 'easeIn'
|
|
16
|
+
| 'easeOut'
|
|
17
|
+
| 'easeInOut'
|
|
18
|
+
| 'circIn'
|
|
19
|
+
| 'circOut'
|
|
20
|
+
| 'circInOut'
|
|
21
|
+
| 'backIn'
|
|
22
|
+
| 'backOut'
|
|
23
|
+
| 'backInOut'
|
|
24
|
+
| 'anticipate'
|
|
25
|
+
| [number, number, number, number]
|
|
26
|
+
|
|
27
|
+
/** How repeated legs play: restart, alternate direction, or alternate
|
|
28
|
+
* direction with inverted easing (identical to reverse for two keyframes). */
|
|
29
|
+
export type RepeatType = 'loop' | 'reverse' | 'mirror'
|
|
30
|
+
/** Numeric tree timing in seconds, applied to inherited animate labels. */
|
|
31
|
+
export interface ChildOrchestration {
|
|
32
|
+
staggerChildren?: number
|
|
33
|
+
delayChildren?: number
|
|
34
|
+
when?: 'beforeChildren' | 'afterChildren'
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
/** Timing is seconds. Spring velocity is units per second. */
|
|
15
|
-
export interface Transition {
|
|
38
|
+
export interface Transition extends ChildOrchestration {
|
|
16
39
|
type?: 'tween' | 'spring'
|
|
40
|
+
/** Tween duration; on springs this is the visual duration (combine with bounce). */
|
|
17
41
|
duration?: number
|
|
18
42
|
delay?: number
|
|
19
43
|
ease?: Ease
|
|
44
|
+
/** Additional iterations after the first leg; Infinity loops forever. */
|
|
45
|
+
repeat?: number
|
|
46
|
+
repeatType?: RepeatType
|
|
47
|
+
repeatDelay?: number
|
|
48
|
+
/** Spring bounciness 0–1; pairs with duration instead of stiffness/damping. */
|
|
49
|
+
bounce?: number
|
|
20
50
|
stiffness?: number
|
|
21
51
|
damping?: number
|
|
22
52
|
mass?: number
|
|
@@ -25,28 +55,87 @@ export interface Transition {
|
|
|
25
55
|
restDelta?: number
|
|
26
56
|
}
|
|
27
57
|
|
|
58
|
+
/** Per-channel transition overrides, upstream form: `{x: {…}, default: {…}}`. */
|
|
59
|
+
export type TransitionOrchestration = ChildOrchestration & {
|
|
60
|
+
default?: Transition
|
|
61
|
+
} & Partial<Record<MotionKey, Transition>>
|
|
62
|
+
|
|
63
|
+
/** A flat transition or per-channel overrides. */
|
|
64
|
+
export type TransitionInput = Transition | TransitionOrchestration
|
|
65
|
+
|
|
66
|
+
/** Labels resolve against this host's variants, in array order. */
|
|
67
|
+
export type VariantLabels = string | string[]
|
|
68
|
+
/** An inline numeric target or labels resolved by the host. */
|
|
69
|
+
export type AnimationDefinition = Target | VariantLabels
|
|
70
|
+
/** A numeric variant destination with an optional run transition override. */
|
|
71
|
+
export type VariantTarget = Target & { transition?: TransitionInput }
|
|
72
|
+
/** Host-local definitions; pure resolvers receive this host's custom value. */
|
|
73
|
+
export type Variants = Record<string, VariantTarget | ((custom: any) => VariantTarget)>
|
|
74
|
+
|
|
28
75
|
/** Static platform styles plus numeric motion-value bindings. */
|
|
29
76
|
export type MotionStyle = Record<string, unknown> & Partial<Record<MotionKey, number | MotionValue>>
|
|
77
|
+
/** Translation limits in CSS pixels/DIP. Omitted edges are unbounded. */
|
|
78
|
+
export interface DragConstraints {
|
|
79
|
+
left?: number
|
|
80
|
+
right?: number
|
|
81
|
+
top?: number
|
|
82
|
+
bottom?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface DragInfo {
|
|
86
|
+
point: { x: number; y: number }
|
|
87
|
+
delta: { x: number; y: number }
|
|
88
|
+
offset: { x: number; y: number }
|
|
89
|
+
/** CSS pixels/DIP per second. */
|
|
90
|
+
velocity: { x: number; y: number }
|
|
91
|
+
cancelled: boolean
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type DragCallback = (event: any, info: DragInfo) => void
|
|
95
|
+
|
|
30
96
|
/** Supported declarative motion controls. */
|
|
31
97
|
export interface MotionProps {
|
|
32
|
-
|
|
98
|
+
drag?: boolean | 'x' | 'y'
|
|
99
|
+
/** Numeric translation bounds; measured ref constraints are deferred. */
|
|
100
|
+
dragConstraints?: DragConstraints
|
|
101
|
+
/** Project release velocity into a spring destination (default true). */
|
|
102
|
+
dragMomentum?: boolean
|
|
103
|
+
/** Resistance beyond bounds, 0–1 (default 0.35); false = 0, true = 0.35. */
|
|
104
|
+
dragElastic?: boolean | number
|
|
105
|
+
onDragStart?: DragCallback
|
|
106
|
+
onDrag?: DragCallback
|
|
107
|
+
onDragEnd?: DragCallback
|
|
108
|
+
/** Named numeric targets; array labels merge left to right. */
|
|
109
|
+
variants?: Variants
|
|
110
|
+
/** Passed to this host's variant resolvers; not inherited. */
|
|
111
|
+
custom?: any
|
|
112
|
+
initial?: AnimationDefinition | false
|
|
33
113
|
/** Destination while the nearest Presence boundary retains this host for exit. */
|
|
34
|
-
exit?:
|
|
35
|
-
animate?:
|
|
36
|
-
transition?:
|
|
114
|
+
exit?: AnimationDefinition
|
|
115
|
+
animate?: AnimationDefinition
|
|
116
|
+
transition?: TransitionInput
|
|
37
117
|
style?: MotionStyle
|
|
118
|
+
/** Target while the pointer is down (web: pointerdown; native: touch down). */
|
|
119
|
+
whileTap?: AnimationDefinition
|
|
120
|
+
whileTapTransition?: Transition
|
|
121
|
+
/** Target while the host holds focus. */
|
|
122
|
+
whileFocus?: AnimationDefinition
|
|
123
|
+
whileFocusTransition?: Transition
|
|
124
|
+
onAnimationStart?: (definition: AnimationDefinition) => void
|
|
38
125
|
onAnimationComplete?: () => void
|
|
126
|
+
/** Per-frame snapshot while any channel animates. */
|
|
127
|
+
onUpdate?: (latest: Target) => void
|
|
39
128
|
}
|
|
40
129
|
|
|
41
130
|
/** View layout and accessibility props with motion controls. */
|
|
42
131
|
export type MotionViewProps = Omit<ViewProps, 'style'> & MotionProps
|
|
43
132
|
/** Row layout and accessibility props with motion controls. */
|
|
44
|
-
export type MotionRowProps = Omit<
|
|
133
|
+
export type MotionRowProps = Omit<HStackProps, 'style'> & MotionProps
|
|
45
134
|
/** Pressable interaction props with motion controls. */
|
|
46
135
|
export type MotionPressableProps = Omit<PressableProps, 'style'> & MotionProps
|
|
47
136
|
/** Inherited defaults; `user` observes the live system preference. */
|
|
48
137
|
export interface MotionConfigProps {
|
|
49
|
-
transition?:
|
|
138
|
+
transition?: TransitionInput
|
|
50
139
|
reducedMotion?: 'always' | 'never' | 'user'
|
|
51
140
|
children?: any
|
|
52
141
|
}
|
package/src/value.ts
CHANGED
|
@@ -88,6 +88,15 @@ export class MotionValue {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
private write(value: number) {
|
|
91
|
+
this.track(value)
|
|
92
|
+
if (!this.disposed && value !== this.previous) {
|
|
93
|
+
this.emit('change', value)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Update position bookkeeping without emitting — platform-delegated runs
|
|
97
|
+
* track presentation state so interruption reads fresh value/velocity. */
|
|
98
|
+
track(value: number) {
|
|
99
|
+
this.assert(value)
|
|
91
100
|
if (this.disposed) {
|
|
92
101
|
return
|
|
93
102
|
}
|
|
@@ -101,12 +110,13 @@ export class MotionValue {
|
|
|
101
110
|
this.previous = this.current
|
|
102
111
|
this.current = value
|
|
103
112
|
this.updated = now
|
|
104
|
-
if (value !== this.previous) {
|
|
105
|
-
this.emit('change', value)
|
|
106
|
-
}
|
|
107
113
|
}
|
|
108
114
|
/** Animate to a destination. New playback replaces old playback. */
|
|
109
|
-
animate(
|
|
115
|
+
animate(
|
|
116
|
+
target: number,
|
|
117
|
+
transition: Transition = {},
|
|
118
|
+
clampSample?: (value: number) => number,
|
|
119
|
+
): AnimationControls {
|
|
110
120
|
this.assert(target)
|
|
111
121
|
validateTransition(transition)
|
|
112
122
|
if (this.disposed) {
|
|
@@ -122,7 +132,7 @@ export class MotionValue {
|
|
|
122
132
|
this.current,
|
|
123
133
|
target,
|
|
124
134
|
{ ...transition, velocity },
|
|
125
|
-
(value) => this.write(value),
|
|
135
|
+
(value) => this.write(clampSample ? clampSample(value) : value),
|
|
126
136
|
)
|
|
127
137
|
|
|
128
138
|
this.controls = controls
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { resolveVariant, playbackTransition, VariantNode } from './variants'
|
|
3
|
+
import type { AnimationResult } from './engine'
|
|
4
|
+
import type { TransitionInput } from './types'
|
|
5
|
+
|
|
6
|
+
function node(timing: TransitionInput = {}) {
|
|
7
|
+
const value = new VariantNode()
|
|
8
|
+
value.resolve = () => ({ target: { x: 10 }, transition: timing })
|
|
9
|
+
value.play = vi.fn(async () => 'finished' as AnimationResult)
|
|
10
|
+
value.stopOwn = vi.fn()
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pending() {
|
|
15
|
+
let resolve!: (result: AnimationResult) => void
|
|
16
|
+
const promise = new Promise<AnimationResult>((done) => {
|
|
17
|
+
resolve = done
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
return { promise, resolve }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('variant resolution', () => {
|
|
24
|
+
it('merges arrays left to right and keeps the last defined transition', () => {
|
|
25
|
+
expect(
|
|
26
|
+
resolveVariant(['missing', 'a', 'b', 'c'], {
|
|
27
|
+
a: { x: 10, opacity: 0, transition: { duration: 1 } },
|
|
28
|
+
b: { x: 20, transition: { duration: 2 } },
|
|
29
|
+
c: { y: 30 },
|
|
30
|
+
}),
|
|
31
|
+
).toEqual({ target: { x: 20, opacity: 0, y: 30 }, transition: { duration: 2 } })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('passes custom to resolvers and validates the returned numeric target', () => {
|
|
35
|
+
expect(resolveVariant('a', { a: (custom) => ({ x: custom }) }, 7).target).toEqual({ x: 7 })
|
|
36
|
+
expect(() => resolveVariant('a', { a: () => ({ x: '10px' as any }) })).toThrow('finite numeric')
|
|
37
|
+
expect(() => resolveVariant('a', { a: { transition: { staggerChildren: -1 } } })).toThrow(
|
|
38
|
+
'staggerChildren',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(resolveVariant('toString', {}).target).toEqual({})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('strips parent timing and adds delay to each per-key override', () => {
|
|
45
|
+
expect(
|
|
46
|
+
playbackTransition(
|
|
47
|
+
{
|
|
48
|
+
default: { duration: 1, delay: 0.1 },
|
|
49
|
+
x: { delay: 0.2 },
|
|
50
|
+
delayChildren: 1,
|
|
51
|
+
staggerChildren: 2,
|
|
52
|
+
when: 'afterChildren',
|
|
53
|
+
},
|
|
54
|
+
0.3,
|
|
55
|
+
),
|
|
56
|
+
).toEqual({ default: { duration: 1, delay: 0.4 }, x: { delay: 0.5 } })
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('variant orchestration', () => {
|
|
61
|
+
it('starts parent and children concurrently with stable stagger offsets', async () => {
|
|
62
|
+
const parent = node({ delayChildren: 0.1, staggerChildren: 0.2 })
|
|
63
|
+
const a = node(),
|
|
64
|
+
b = node()
|
|
65
|
+
|
|
66
|
+
parent.register(a)
|
|
67
|
+
parent.register(b)
|
|
68
|
+
const job = pending()
|
|
69
|
+
parent.play = vi.fn(() => job.promise)
|
|
70
|
+
const result = parent.run('show')
|
|
71
|
+
expect(a.play).toHaveBeenCalledWith('show', { x: 10 }, { delay: 0.1 })
|
|
72
|
+
expect(b.play).toHaveBeenCalledWith('show', { x: 10 }, { delay: 0.30000000000000004 })
|
|
73
|
+
job.resolve('finished')
|
|
74
|
+
expect(await result).toBe('finished')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('waits for actual parent completion before children', async () => {
|
|
78
|
+
const parent = node({ when: 'beforeChildren' }),
|
|
79
|
+
child = node()
|
|
80
|
+
|
|
81
|
+
parent.register(child)
|
|
82
|
+
const job = pending()
|
|
83
|
+
parent.play = vi.fn(() => job.promise)
|
|
84
|
+
const result = parent.run('show')
|
|
85
|
+
expect(child.play).not.toHaveBeenCalled()
|
|
86
|
+
job.resolve('finished')
|
|
87
|
+
expect(await result).toBe('finished')
|
|
88
|
+
expect(child.play).toHaveBeenCalledOnce()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('waits for children and cancels queued parent work on disposal', async () => {
|
|
92
|
+
const parent = node({ when: 'afterChildren' }),
|
|
93
|
+
child = node()
|
|
94
|
+
|
|
95
|
+
parent.register(child)
|
|
96
|
+
const job = pending()
|
|
97
|
+
child.play = vi.fn(() => job.promise)
|
|
98
|
+
const result = parent.run('show')
|
|
99
|
+
expect(parent.play).not.toHaveBeenCalled()
|
|
100
|
+
parent.stop()
|
|
101
|
+
job.resolve('finished')
|
|
102
|
+
expect(await result).toBe('cancelled')
|
|
103
|
+
expect(parent.play).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('unregisters children and propagates cancellation through nested runs', async () => {
|
|
107
|
+
const parent = node(),
|
|
108
|
+
child = node(),
|
|
109
|
+
grandchild = node()
|
|
110
|
+
|
|
111
|
+
child.register(grandchild)
|
|
112
|
+
const off = parent.register(child)
|
|
113
|
+
await parent.run('show')
|
|
114
|
+
expect(grandchild.play).toHaveBeenCalledOnce()
|
|
115
|
+
off()
|
|
116
|
+
await parent.run('hide')
|
|
117
|
+
expect(child.play).toHaveBeenCalledOnce()
|
|
118
|
+
expect(child.stopOwn).toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('starts the parent only after every child finishes successfully', async () => {
|
|
123
|
+
const parent = node({ when: 'afterChildren' })
|
|
124
|
+
const a = node(),
|
|
125
|
+
b = node()
|
|
126
|
+
|
|
127
|
+
parent.register(a)
|
|
128
|
+
parent.register(b)
|
|
129
|
+
const first = pending(),
|
|
130
|
+
second = pending()
|
|
131
|
+
|
|
132
|
+
a.play = vi.fn(() => first.promise)
|
|
133
|
+
b.play = vi.fn(() => second.promise)
|
|
134
|
+
const result = parent.run('show')
|
|
135
|
+
first.resolve('finished')
|
|
136
|
+
await Promise.resolve()
|
|
137
|
+
expect(parent.play).not.toHaveBeenCalled()
|
|
138
|
+
second.resolve('finished')
|
|
139
|
+
expect(await result).toBe('finished')
|
|
140
|
+
expect(parent.play).toHaveBeenCalledOnce()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('does not launch an obsolete child phase after replacement', async () => {
|
|
144
|
+
const parent = node({ when: 'beforeChildren' }),
|
|
145
|
+
child = node()
|
|
146
|
+
|
|
147
|
+
parent.register(child)
|
|
148
|
+
const old = pending()
|
|
149
|
+
parent.play = vi.fn(() => old.promise)
|
|
150
|
+
const obsolete = parent.run('show')
|
|
151
|
+
parent.play = vi.fn(async () => 'finished')
|
|
152
|
+
await parent.run('hide')
|
|
153
|
+
old.resolve('finished')
|
|
154
|
+
expect(await obsolete).toBe('cancelled')
|
|
155
|
+
expect(child.play).toHaveBeenCalledTimes(1)
|
|
156
|
+
expect(child.play).toHaveBeenCalledWith('hide', { x: 10 }, { delay: 0 })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('animates late children after the current parent run finishes', async () => {
|
|
160
|
+
const parent = node({ when: 'beforeChildren', delayChildren: 0.1 }),
|
|
161
|
+
child = node()
|
|
162
|
+
|
|
163
|
+
await parent.run('show')
|
|
164
|
+
parent.register(child)
|
|
165
|
+
await Promise.resolve()
|
|
166
|
+
expect(child.play).toHaveBeenCalledWith('show', { x: 10 }, { delay: 0.1 })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('keeps independently started exits alive while invalidating queued tree work', async () => {
|
|
170
|
+
const parent = node({ when: 'beforeChildren' }),
|
|
171
|
+
child = node()
|
|
172
|
+
|
|
173
|
+
parent.register(child)
|
|
174
|
+
const job = pending()
|
|
175
|
+
parent.play = vi.fn(() => job.promise)
|
|
176
|
+
const run = parent.run('show')
|
|
177
|
+
const stops = (child.stopOwn as ReturnType<typeof vi.fn>).mock.calls.length
|
|
178
|
+
parent.cancelPending()
|
|
179
|
+
job.resolve('finished')
|
|
180
|
+
expect(await run).toBe('cancelled')
|
|
181
|
+
expect(child.stopOwn).toHaveBeenCalledTimes(stops)
|
|
182
|
+
expect(child.play).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('does not count a late child twice while waiting for the parent phase', async () => {
|
|
186
|
+
const parent = node({ when: 'beforeChildren' }),
|
|
187
|
+
child = node()
|
|
188
|
+
|
|
189
|
+
const job = pending()
|
|
190
|
+
parent.play = vi.fn(() => job.promise)
|
|
191
|
+
const run = parent.run('show')
|
|
192
|
+
parent.register(child)
|
|
193
|
+
job.resolve('finished')
|
|
194
|
+
await run
|
|
195
|
+
await Promise.resolve()
|
|
196
|
+
expect(child.play).toHaveBeenCalledOnce()
|
|
197
|
+
})
|