@tamagui/animations-css 2.0.0-rc.3 → 2.0.0-rc.30
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/dist/cjs/createAnimations.cjs +121 -25
- package/dist/cjs/createAnimations.native.js +192 -25
- package/dist/cjs/createAnimations.native.js.map +1 -1
- package/dist/cjs/index.js +11 -8
- package/dist/esm/createAnimations.mjs +123 -27
- package/dist/esm/createAnimations.mjs.map +1 -1
- package/dist/esm/createAnimations.native.js +194 -27
- package/dist/esm/createAnimations.native.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -6
- package/package.json +10 -13
- package/src/createAnimations.tsx +423 -28
- package/types/createAnimations.d.ts.map +4 -4
- package/types/index.d.ts.map +2 -2
- package/dist/cjs/createAnimations.js +0 -120
- package/dist/cjs/createAnimations.js.map +0 -6
- package/dist/esm/createAnimations.js +0 -105
- package/dist/esm/createAnimations.js.map +0 -6
package/dist/esm/index.js.map
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tamagui/animations-css",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
3
|
+
"version": "2.0.0-rc.30",
|
|
4
4
|
"gitHead": "a49cc7ea6b93ba384e77a4880ae48ac4a5635c14",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"source": "src/index.ts",
|
|
@@ -18,15 +18,12 @@
|
|
|
18
18
|
"./package.json": "./package.json",
|
|
19
19
|
".": {
|
|
20
20
|
"types": "./types/index.d.ts",
|
|
21
|
-
"react-native":
|
|
22
|
-
|
|
23
|
-
"import": "./dist/esm/index.native.js",
|
|
24
|
-
"require": "./dist/cjs/index.native.js"
|
|
25
|
-
},
|
|
21
|
+
"react-native": "./dist/esm/index.native.js",
|
|
22
|
+
"browser": "./dist/esm/index.mjs",
|
|
26
23
|
"module": "./dist/esm/index.mjs",
|
|
27
24
|
"import": "./dist/esm/index.mjs",
|
|
28
25
|
"require": "./dist/cjs/index.cjs",
|
|
29
|
-
"default": "./dist/
|
|
26
|
+
"default": "./dist/esm/index.mjs"
|
|
30
27
|
}
|
|
31
28
|
},
|
|
32
29
|
"publishConfig": {
|
|
@@ -39,14 +36,14 @@
|
|
|
39
36
|
"clean:build": "tamagui-build clean:build"
|
|
40
37
|
},
|
|
41
38
|
"dependencies": {
|
|
42
|
-
"@tamagui/animation-helpers": "2.0.0-rc.
|
|
43
|
-
"@tamagui/constants": "2.0.0-rc.
|
|
44
|
-
"@tamagui/cubic-bezier-animator": "2.0.0-rc.
|
|
45
|
-
"@tamagui/use-presence": "2.0.0-rc.
|
|
46
|
-
"@tamagui/web": "2.0.0-rc.
|
|
39
|
+
"@tamagui/animation-helpers": "2.0.0-rc.30",
|
|
40
|
+
"@tamagui/constants": "2.0.0-rc.30",
|
|
41
|
+
"@tamagui/cubic-bezier-animator": "2.0.0-rc.30",
|
|
42
|
+
"@tamagui/use-presence": "2.0.0-rc.30",
|
|
43
|
+
"@tamagui/web": "2.0.0-rc.30"
|
|
47
44
|
},
|
|
48
45
|
"devDependencies": {
|
|
49
|
-
"@tamagui/build": "2.0.0-rc.
|
|
46
|
+
"@tamagui/build": "2.0.0-rc.30",
|
|
50
47
|
"react": ">=19",
|
|
51
48
|
"react-dom": "*"
|
|
52
49
|
},
|
package/src/createAnimations.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
getAnimatedProperties,
|
|
4
4
|
hasAnimation as hasNormalizedAnimation,
|
|
5
5
|
getEffectiveAnimation,
|
|
6
|
+
getAnimationConfigsForKeys,
|
|
6
7
|
} from '@tamagui/animation-helpers'
|
|
7
8
|
import { useIsomorphicLayoutEffect } from '@tamagui/constants'
|
|
8
9
|
import { ResetPresence, usePresence } from '@tamagui/use-presence'
|
|
@@ -10,6 +11,9 @@ import type { AnimationDriver, UniversalAnimatedNumber } from '@tamagui/web'
|
|
|
10
11
|
import { transformsToString } from '@tamagui/web'
|
|
11
12
|
import React, { useState } from 'react' // import { animate } from '@tamagui/cubic-bezier-animator'
|
|
12
13
|
|
|
14
|
+
const EXTRACT_MS_REGEX = /(\d+(?:\.\d+)?)\s*ms/
|
|
15
|
+
const EXTRACT_S_REGEX = /(\d+(?:\.\d+)?)\s*s/
|
|
16
|
+
|
|
13
17
|
/**
|
|
14
18
|
* Helper function to extract duration from CSS animation string
|
|
15
19
|
* Examples: "ease-in 200ms" -> 200, "cubic-bezier(0.215, 0.610, 0.355, 1.000) 400ms" -> 400
|
|
@@ -17,13 +21,13 @@ import React, { useState } from 'react' // import { animate } from '@tamagui/cub
|
|
|
17
21
|
*/
|
|
18
22
|
function extractDuration(animation: string): number {
|
|
19
23
|
// Try to match milliseconds first
|
|
20
|
-
const msMatch = animation.match(
|
|
24
|
+
const msMatch = animation.match(EXTRACT_MS_REGEX)
|
|
21
25
|
if (msMatch) {
|
|
22
26
|
return Number.parseInt(msMatch[1], 10)
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// Try to match seconds and convert to milliseconds
|
|
26
|
-
const sMatch = animation.match(
|
|
30
|
+
const sMatch = animation.match(EXTRACT_S_REGEX)
|
|
27
31
|
if (sMatch) {
|
|
28
32
|
return Math.round(Number.parseFloat(sMatch[1]) * 1000)
|
|
29
33
|
}
|
|
@@ -32,6 +36,124 @@ function extractDuration(animation: string): number {
|
|
|
32
36
|
return 300
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
const MS_DURATION_REGEX = /(\d+(?:\.\d+)?)\s*ms/
|
|
40
|
+
const S_DURATION_REGEX = /(\d+(?:\.\d+)?)\s*s(?!tiffness)/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Apply duration override to a CSS animation string
|
|
44
|
+
* Replaces the existing duration with the override value
|
|
45
|
+
*/
|
|
46
|
+
function applyDurationOverride(animation: string, durationMs: number): string {
|
|
47
|
+
// Replace ms duration
|
|
48
|
+
const msReplaced = animation.replace(MS_DURATION_REGEX, `${durationMs}ms`)
|
|
49
|
+
if (msReplaced !== animation) {
|
|
50
|
+
return msReplaced
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Replace seconds duration
|
|
54
|
+
const sReplaced = animation.replace(S_DURATION_REGEX, `${durationMs}ms`)
|
|
55
|
+
if (sReplaced !== animation) {
|
|
56
|
+
return sReplaced
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// No duration found, prepend the duration
|
|
60
|
+
return `${durationMs}ms ${animation}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// transform keys that need special handling
|
|
64
|
+
const TRANSFORM_KEYS = [
|
|
65
|
+
'x',
|
|
66
|
+
'y',
|
|
67
|
+
'scale',
|
|
68
|
+
'scaleX',
|
|
69
|
+
'scaleY',
|
|
70
|
+
'rotate',
|
|
71
|
+
'rotateX',
|
|
72
|
+
'rotateY',
|
|
73
|
+
'rotateZ',
|
|
74
|
+
'skewX',
|
|
75
|
+
'skewY',
|
|
76
|
+
] as const
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a CSS transform string from a style object containing transform properties
|
|
80
|
+
*/
|
|
81
|
+
function buildTransformString(style: Record<string, unknown> | undefined): string {
|
|
82
|
+
if (!style) return ''
|
|
83
|
+
|
|
84
|
+
const parts: string[] = []
|
|
85
|
+
|
|
86
|
+
if (style.x !== undefined || style.y !== undefined) {
|
|
87
|
+
const x = style.x ?? 0
|
|
88
|
+
const y = style.y ?? 0
|
|
89
|
+
parts.push(`translate(${x}px, ${y}px)`)
|
|
90
|
+
}
|
|
91
|
+
if (style.scale !== undefined) {
|
|
92
|
+
parts.push(`scale(${style.scale})`)
|
|
93
|
+
}
|
|
94
|
+
if (style.scaleX !== undefined) {
|
|
95
|
+
parts.push(`scaleX(${style.scaleX})`)
|
|
96
|
+
}
|
|
97
|
+
if (style.scaleY !== undefined) {
|
|
98
|
+
parts.push(`scaleY(${style.scaleY})`)
|
|
99
|
+
}
|
|
100
|
+
if (style.rotate !== undefined) {
|
|
101
|
+
const val = style.rotate
|
|
102
|
+
const unit = typeof val === 'string' && val.includes('deg') ? '' : 'deg'
|
|
103
|
+
parts.push(`rotate(${val}${unit})`)
|
|
104
|
+
}
|
|
105
|
+
if (style.rotateX !== undefined) {
|
|
106
|
+
parts.push(`rotateX(${style.rotateX}deg)`)
|
|
107
|
+
}
|
|
108
|
+
if (style.rotateY !== undefined) {
|
|
109
|
+
parts.push(`rotateY(${style.rotateY}deg)`)
|
|
110
|
+
}
|
|
111
|
+
if (style.rotateZ !== undefined) {
|
|
112
|
+
parts.push(`rotateZ(${style.rotateZ}deg)`)
|
|
113
|
+
}
|
|
114
|
+
if (style.skewX !== undefined) {
|
|
115
|
+
parts.push(`skewX(${style.skewX}deg)`)
|
|
116
|
+
}
|
|
117
|
+
if (style.skewY !== undefined) {
|
|
118
|
+
parts.push(`skewY(${style.skewY}deg)`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return parts.join(' ')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Apply a style object to a DOM node, handling transform keys specially
|
|
126
|
+
*/
|
|
127
|
+
function applyStylesToNode(
|
|
128
|
+
node: HTMLElement,
|
|
129
|
+
style: Record<string, unknown> | undefined
|
|
130
|
+
): void {
|
|
131
|
+
if (!style) return
|
|
132
|
+
|
|
133
|
+
// collect transform values
|
|
134
|
+
const transformStr = buildTransformString(style)
|
|
135
|
+
if (transformStr) {
|
|
136
|
+
node.style.transform = transformStr
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// apply non-transform properties
|
|
140
|
+
for (const [key, value] of Object.entries(style)) {
|
|
141
|
+
if (TRANSFORM_KEYS.includes(key as any)) continue
|
|
142
|
+
if (value === undefined) continue
|
|
143
|
+
|
|
144
|
+
if (key === 'opacity') {
|
|
145
|
+
node.style.opacity = String(value)
|
|
146
|
+
} else if (key === 'backgroundColor') {
|
|
147
|
+
node.style.backgroundColor = String(value)
|
|
148
|
+
} else if (key === 'color') {
|
|
149
|
+
node.style.color = String(value)
|
|
150
|
+
} else {
|
|
151
|
+
// generic fallback
|
|
152
|
+
node.style[key as any] = typeof value === 'number' ? `${value}px` : String(value)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
35
157
|
export function createAnimations<A extends object>(animations: A): AnimationDriver<A> {
|
|
36
158
|
const reactionListeners = new WeakMap<any, Set<Function>>()
|
|
37
159
|
|
|
@@ -39,21 +161,12 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
|
|
|
39
161
|
animations,
|
|
40
162
|
usePresence,
|
|
41
163
|
ResetPresence,
|
|
42
|
-
supportsCSS: true,
|
|
43
164
|
inputStyle: 'css',
|
|
44
165
|
outputStyle: 'css',
|
|
45
|
-
classNameAnimation: true,
|
|
46
166
|
|
|
47
167
|
useAnimatedNumber(initial): UniversalAnimatedNumber<Function> {
|
|
48
168
|
const [val, setVal] = React.useState(initial)
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
useIsomorphicLayoutEffect(() => {
|
|
52
|
-
if (onFinish) {
|
|
53
|
-
onFinish?.()
|
|
54
|
-
setOnFinish(undefined)
|
|
55
|
-
}
|
|
56
|
-
}, [onFinish])
|
|
169
|
+
const finishTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
57
170
|
|
|
58
171
|
return {
|
|
59
172
|
getInstance() {
|
|
@@ -64,14 +177,40 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
|
|
|
64
177
|
},
|
|
65
178
|
setValue(next, config, onFinish) {
|
|
66
179
|
setVal(next)
|
|
67
|
-
|
|
180
|
+
|
|
181
|
+
// clear any pending finish callback from a previous setValue
|
|
182
|
+
if (finishTimerRef.current) {
|
|
183
|
+
clearTimeout(finishTimerRef.current)
|
|
184
|
+
finishTimerRef.current = null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (onFinish) {
|
|
188
|
+
if (
|
|
189
|
+
!config ||
|
|
190
|
+
config.type === 'direct' ||
|
|
191
|
+
(config.type === 'timing' && config.duration === 0)
|
|
192
|
+
) {
|
|
193
|
+
onFinish()
|
|
194
|
+
} else {
|
|
195
|
+
// estimate duration: use explicit duration, or fall back to
|
|
196
|
+
// default CSS transition duration for spring-type configs
|
|
197
|
+
const duration = config.type === 'timing' ? config.duration : 300
|
|
198
|
+
finishTimerRef.current = setTimeout(onFinish, duration)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
68
202
|
// call reaction listeners with the new value
|
|
69
203
|
const listeners = reactionListeners.get(setVal)
|
|
70
204
|
if (listeners) {
|
|
71
205
|
listeners.forEach((listener) => listener(next))
|
|
72
206
|
}
|
|
73
207
|
},
|
|
74
|
-
stop() {
|
|
208
|
+
stop() {
|
|
209
|
+
if (finishTimerRef.current) {
|
|
210
|
+
clearTimeout(finishTimerRef.current)
|
|
211
|
+
finishTimerRef.current = null
|
|
212
|
+
}
|
|
213
|
+
},
|
|
75
214
|
}
|
|
76
215
|
},
|
|
77
216
|
|
|
@@ -95,7 +234,16 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
|
|
|
95
234
|
return getStyle(val.getValue())
|
|
96
235
|
},
|
|
97
236
|
|
|
98
|
-
|
|
237
|
+
// @ts-ignore - styleState is added by createComponent
|
|
238
|
+
useAnimations: ({
|
|
239
|
+
props,
|
|
240
|
+
presence,
|
|
241
|
+
style,
|
|
242
|
+
componentState,
|
|
243
|
+
stateRef,
|
|
244
|
+
styleState,
|
|
245
|
+
}: any) => {
|
|
246
|
+
const isHydrating = componentState.unmounted === true
|
|
99
247
|
const isEntering = !!componentState.unmounted
|
|
100
248
|
const isExiting = presence?.[0] === false
|
|
101
249
|
const sendExitComplete = presence?.[1]
|
|
@@ -108,8 +256,37 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
|
|
|
108
256
|
wasEnteringRef.current = isEntering
|
|
109
257
|
})
|
|
110
258
|
|
|
259
|
+
// exit cycle guards to prevent stale/duplicate completion
|
|
260
|
+
const exitCycleIdRef = React.useRef(0)
|
|
261
|
+
const exitCompletedRef = React.useRef(false)
|
|
262
|
+
const wasExitingRef = React.useRef(false)
|
|
263
|
+
const exitInterruptedRef = React.useRef(false)
|
|
264
|
+
|
|
265
|
+
// detect transition into/out of exiting state
|
|
266
|
+
const justStartedExiting = isExiting && !wasExitingRef.current
|
|
267
|
+
const justStoppedExiting = !isExiting && wasExitingRef.current
|
|
268
|
+
|
|
269
|
+
// start new exit cycle only on transition INTO exiting
|
|
270
|
+
if (justStartedExiting) {
|
|
271
|
+
exitCycleIdRef.current++
|
|
272
|
+
exitCompletedRef.current = false
|
|
273
|
+
}
|
|
274
|
+
// track interruptions so we know to force-restart transitions
|
|
275
|
+
if (justStoppedExiting) {
|
|
276
|
+
exitCycleIdRef.current++
|
|
277
|
+
exitInterruptedRef.current = true
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// track previous exiting state
|
|
281
|
+
React.useEffect(() => {
|
|
282
|
+
wasExitingRef.current = isExiting
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// use effectiveTransition computed by createComponent (single source of truth)
|
|
286
|
+
const effectiveTransition = styleState?.effectiveTransition ?? props.transition
|
|
287
|
+
|
|
111
288
|
// Normalize the transition prop to a consistent format
|
|
112
|
-
const normalized = normalizeTransition(
|
|
289
|
+
const normalized = normalizeTransition(effectiveTransition)
|
|
113
290
|
|
|
114
291
|
// Determine animation state and get effective animation
|
|
115
292
|
// Use 'enter' if we're entering OR if we just finished entering (transition is happening)
|
|
@@ -155,6 +332,137 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
|
|
|
155
332
|
if (!sendExitComplete || !isExiting || !host) return
|
|
156
333
|
const node = host as HTMLElement
|
|
157
334
|
|
|
335
|
+
// capture current cycle id for this effect
|
|
336
|
+
const cycleId = exitCycleIdRef.current
|
|
337
|
+
|
|
338
|
+
// helper to complete exit with guards
|
|
339
|
+
const completeExit = () => {
|
|
340
|
+
if (cycleId !== exitCycleIdRef.current) return
|
|
341
|
+
if (exitCompletedRef.current) return
|
|
342
|
+
exitCompletedRef.current = true
|
|
343
|
+
sendExitComplete()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// if no properties to animate (animateOnly=[]), complete immediately
|
|
347
|
+
if (keys.length === 0) {
|
|
348
|
+
completeExit()
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Force transition restart for interrupted exits
|
|
353
|
+
// When an exit is interrupted and restarted, the element may already be at
|
|
354
|
+
// the exit style, so no CSS transition fires. We need to:
|
|
355
|
+
// 1. Reset to non-exit state
|
|
356
|
+
// 2. Force reflow
|
|
357
|
+
// 3. Re-apply exit state to trigger transition
|
|
358
|
+
let rafId: number | undefined
|
|
359
|
+
const wasInterrupted = exitInterruptedRef.current
|
|
360
|
+
// flag to ignore transitioncancel during reset (we intentionally cancel the old transition)
|
|
361
|
+
let ignoreCancelEvents = wasInterrupted
|
|
362
|
+
// get enter/exit styles for potential restart
|
|
363
|
+
const enterStyle = props.enterStyle as Record<string, unknown> | undefined
|
|
364
|
+
const exitStyle = props.exitStyle as Record<string, unknown> | undefined
|
|
365
|
+
|
|
366
|
+
// Build the exit transition string - needed for both normal and interrupted exits
|
|
367
|
+
const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''
|
|
368
|
+
const durationOverride = normalized.config?.duration
|
|
369
|
+
const exitTransitionString = keys
|
|
370
|
+
.map((key) => {
|
|
371
|
+
const propAnimation = normalized.properties[key]
|
|
372
|
+
let animationValue: string | null = null
|
|
373
|
+
if (typeof propAnimation === 'string') {
|
|
374
|
+
animationValue = animations[propAnimation]
|
|
375
|
+
} else if (
|
|
376
|
+
propAnimation &&
|
|
377
|
+
typeof propAnimation === 'object' &&
|
|
378
|
+
propAnimation.type
|
|
379
|
+
) {
|
|
380
|
+
animationValue = animations[propAnimation.type]
|
|
381
|
+
} else if (defaultAnimation) {
|
|
382
|
+
animationValue = defaultAnimation
|
|
383
|
+
}
|
|
384
|
+
if (animationValue && durationOverride) {
|
|
385
|
+
animationValue = applyDurationOverride(animationValue, durationOverride)
|
|
386
|
+
}
|
|
387
|
+
return animationValue ? `${key} ${animationValue}${delayStr}` : null
|
|
388
|
+
})
|
|
389
|
+
.filter(Boolean)
|
|
390
|
+
.join(', ')
|
|
391
|
+
|
|
392
|
+
if (wasInterrupted) {
|
|
393
|
+
exitInterruptedRef.current = false
|
|
394
|
+
// disable transition, reset to enter state
|
|
395
|
+
node.style.transition = 'none'
|
|
396
|
+
|
|
397
|
+
// reset: apply active/open state for each exit property (not enterStyle,
|
|
398
|
+
// which may equal exitStyle — see comment in the normal exit path below)
|
|
399
|
+
if (exitStyle) {
|
|
400
|
+
const resetStyle: Record<string, unknown> = {}
|
|
401
|
+
for (const key of Object.keys(exitStyle)) {
|
|
402
|
+
if (key === 'opacity') {
|
|
403
|
+
resetStyle[key] = 1
|
|
404
|
+
} else if (TRANSFORM_KEYS.includes(key as any)) {
|
|
405
|
+
resetStyle[key] =
|
|
406
|
+
key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
|
|
407
|
+
} else if (enterStyle?.[key] !== undefined) {
|
|
408
|
+
resetStyle[key] = enterStyle[key]
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
applyStylesToNode(node, resetStyle)
|
|
412
|
+
} else {
|
|
413
|
+
// fallback if no exitStyle defined
|
|
414
|
+
node.style.opacity = '1'
|
|
415
|
+
node.style.transform = 'none'
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// force reflow
|
|
419
|
+
void node.offsetHeight
|
|
420
|
+
} else if (exitStyle) {
|
|
421
|
+
// For normal (non-interrupted) exits, we need to ensure the CSS transition is
|
|
422
|
+
// processed by the browser BEFORE the exitStyle takes effect. The issue is that
|
|
423
|
+
// React may have already applied exitStyle in the same render batch. To fix this:
|
|
424
|
+
// 1. Disable transition and reset to non-exit state
|
|
425
|
+
// 2. Force reflow so browser processes the reset
|
|
426
|
+
// 3. Use RAF to ensure we're in a new frame
|
|
427
|
+
// 4. Re-enable transition and apply exitStyle
|
|
428
|
+
// This mirrors the interrupted exit handling approach (which also uses RAF).
|
|
429
|
+
ignoreCancelEvents = true
|
|
430
|
+
node.style.transition = 'none'
|
|
431
|
+
|
|
432
|
+
// Reset to the active/open state (not enterStyle, which may equal exitStyle).
|
|
433
|
+
// enterStyle is the "unmounted" initial state and can share values with exitStyle
|
|
434
|
+
// (e.g., both have opacity: 0). resetting to enterStyle would mean no value change
|
|
435
|
+
// when exitStyle is applied, so the CSS transition wouldn't fire.
|
|
436
|
+
const resetStyle: Record<string, unknown> = {}
|
|
437
|
+
for (const key of Object.keys(exitStyle)) {
|
|
438
|
+
if (key === 'opacity') {
|
|
439
|
+
resetStyle[key] = 1
|
|
440
|
+
} else if (TRANSFORM_KEYS.includes(key as any)) {
|
|
441
|
+
resetStyle[key] =
|
|
442
|
+
key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
|
|
443
|
+
} else if (enterStyle?.[key] !== undefined) {
|
|
444
|
+
resetStyle[key] = enterStyle[key]
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
applyStylesToNode(node, resetStyle)
|
|
448
|
+
|
|
449
|
+
// Force reflow
|
|
450
|
+
void node.offsetHeight
|
|
451
|
+
|
|
452
|
+
// Use RAF to ensure transition is applied in a new frame
|
|
453
|
+
rafId = requestAnimationFrame(() => {
|
|
454
|
+
if (cycleId !== exitCycleIdRef.current) return
|
|
455
|
+
// Re-enable transition
|
|
456
|
+
node.style.transition = exitTransitionString
|
|
457
|
+
// Force reflow to ensure transition is active
|
|
458
|
+
void node.offsetHeight
|
|
459
|
+
// Apply exit styles - this triggers the animation
|
|
460
|
+
applyStylesToNode(node, exitStyle)
|
|
461
|
+
// Re-enable cancel event handling
|
|
462
|
+
ignoreCancelEvents = false
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
158
466
|
/**
|
|
159
467
|
* Exit animation handling for Dialog/Modal components
|
|
160
468
|
*
|
|
@@ -162,34 +470,115 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
|
|
|
162
470
|
* the element can disappear from the DOM before CSS transitions finish, which causes:
|
|
163
471
|
* 1. Dialogs to stick around on screen
|
|
164
472
|
* 2. Event handlers to stop working
|
|
473
|
+
*
|
|
474
|
+
* Fix: Calculate the MAXIMUM duration across all animated properties, not just
|
|
475
|
+
* the default. With animateOnly and per-property configs, different properties
|
|
476
|
+
* can have different durations, and we need to wait for the LONGEST one.
|
|
165
477
|
*/
|
|
166
478
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
479
|
+
// calculate max duration across all animated properties
|
|
480
|
+
let maxDuration = defaultAnimation ? extractDuration(defaultAnimation) : 200
|
|
481
|
+
|
|
482
|
+
// check per-property animation durations using shared helper
|
|
483
|
+
const animationConfigs = getAnimationConfigsForKeys(
|
|
484
|
+
normalized,
|
|
485
|
+
animations as Record<string, string>,
|
|
486
|
+
keys,
|
|
487
|
+
defaultAnimation
|
|
488
|
+
)
|
|
489
|
+
for (const animationValue of animationConfigs.values()) {
|
|
490
|
+
if (animationValue) {
|
|
491
|
+
const duration = extractDuration(animationValue)
|
|
492
|
+
if (duration > maxDuration) {
|
|
493
|
+
maxDuration = duration
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
171
498
|
const delay = normalized.delay ?? 0
|
|
172
|
-
const fallbackTimeout =
|
|
499
|
+
const fallbackTimeout = maxDuration + delay
|
|
173
500
|
|
|
174
501
|
const timeoutId = setTimeout(() => {
|
|
175
|
-
|
|
502
|
+
completeExit()
|
|
176
503
|
}, fallbackTimeout)
|
|
177
504
|
|
|
178
|
-
//
|
|
179
|
-
|
|
505
|
+
// track number of transitioning properties to wait for all to finish
|
|
506
|
+
// (each property fires its own transitionend event)
|
|
507
|
+
const transitioningProps = new Set(keys)
|
|
508
|
+
let completedCount = 0
|
|
509
|
+
|
|
510
|
+
const onFinishAnimation = (event: TransitionEvent) => {
|
|
511
|
+
// only count transitions on THIS element, not bubbled from children
|
|
512
|
+
if (event.target !== node) return
|
|
513
|
+
|
|
514
|
+
// map CSS property names to our key names
|
|
515
|
+
// e.g., transitionend fires with propertyName 'transform' for scale/x/y
|
|
516
|
+
const eventProp = event.propertyName
|
|
517
|
+
if (transitioningProps.has(eventProp) || eventProp === 'all') {
|
|
518
|
+
completedCount++
|
|
519
|
+
// wait for all properties to finish
|
|
520
|
+
if (completedCount >= transitioningProps.size) {
|
|
521
|
+
clearTimeout(timeoutId)
|
|
522
|
+
completeExit()
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// on cancel, still complete (element is exiting and animation was interrupted)
|
|
528
|
+
// the guards prevent duplicate completion if this is a stale cycle
|
|
529
|
+
const onCancelAnimation = () => {
|
|
530
|
+
// ignore cancel events during reset phase (we intentionally cancel the old transition)
|
|
531
|
+
if (ignoreCancelEvents) return
|
|
180
532
|
clearTimeout(timeoutId)
|
|
181
|
-
|
|
533
|
+
completeExit()
|
|
182
534
|
}
|
|
183
535
|
|
|
184
536
|
node.addEventListener('transitionend', onFinishAnimation)
|
|
185
|
-
node.addEventListener('transitioncancel',
|
|
537
|
+
node.addEventListener('transitioncancel', onCancelAnimation)
|
|
538
|
+
|
|
539
|
+
// For interrupted exits, re-enable transition and re-apply exit styles
|
|
540
|
+
// This must happen AFTER listeners are set up so we catch the transitionend
|
|
541
|
+
if (wasInterrupted) {
|
|
542
|
+
rafId = requestAnimationFrame(() => {
|
|
543
|
+
if (cycleId !== exitCycleIdRef.current) return
|
|
544
|
+
// re-enable transition using the pre-built string
|
|
545
|
+
node.style.transition = exitTransitionString
|
|
546
|
+
// force reflow again
|
|
547
|
+
void node.offsetHeight
|
|
548
|
+
// now apply exit styles - this triggers the transition
|
|
549
|
+
applyStylesToNode(node, exitStyle)
|
|
550
|
+
// re-enable cancel event handling now that reset is complete
|
|
551
|
+
ignoreCancelEvents = false
|
|
552
|
+
})
|
|
553
|
+
}
|
|
186
554
|
|
|
187
555
|
return () => {
|
|
188
556
|
clearTimeout(timeoutId)
|
|
557
|
+
if (rafId !== undefined) cancelAnimationFrame(rafId)
|
|
189
558
|
node.removeEventListener('transitionend', onFinishAnimation)
|
|
190
|
-
node.removeEventListener('transitioncancel',
|
|
559
|
+
node.removeEventListener('transitioncancel', onCancelAnimation)
|
|
560
|
+
// restore transition: the exit handling sets node.style.transition='none'
|
|
561
|
+
// directly on the DOM (bypassing React). if exit is interrupted (e.g. same-key
|
|
562
|
+
// re-entry in AnimatePresence), React won't re-apply its managed transition
|
|
563
|
+
// value because it hasn't changed in the virtual DOM. clearing the inline
|
|
564
|
+
// override lets React's value take effect again.
|
|
565
|
+
node.style.transition = ''
|
|
191
566
|
}
|
|
192
|
-
}, [
|
|
567
|
+
}, [
|
|
568
|
+
sendExitComplete,
|
|
569
|
+
isExiting,
|
|
570
|
+
stateRef,
|
|
571
|
+
keys,
|
|
572
|
+
normalized,
|
|
573
|
+
defaultAnimation,
|
|
574
|
+
props.enterStyle,
|
|
575
|
+
props.exitStyle,
|
|
576
|
+
])
|
|
577
|
+
|
|
578
|
+
// tamagui doesnt even use animation output during hydration
|
|
579
|
+
if (isHydrating) {
|
|
580
|
+
return null
|
|
581
|
+
}
|
|
193
582
|
|
|
194
583
|
// Check if we have any animation to apply
|
|
195
584
|
if (!hasNormalizedAnimation(normalized)) {
|
|
@@ -204,6 +593,7 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
|
|
|
204
593
|
// TODO: we disabled the transform transition, because it will create issue for inverse function and animate function
|
|
205
594
|
// for non layout transform properties either use animate function or find a workaround to do it with css
|
|
206
595
|
const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''
|
|
596
|
+
const durationOverride = normalized.config?.duration
|
|
207
597
|
style.transition = keys
|
|
208
598
|
.map((key) => {
|
|
209
599
|
// Check for property-specific animation, fall back to default
|
|
@@ -222,6 +612,11 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
|
|
|
222
612
|
animationValue = defaultAnimation
|
|
223
613
|
}
|
|
224
614
|
|
|
615
|
+
// Apply global duration override if specified
|
|
616
|
+
if (animationValue && durationOverride) {
|
|
617
|
+
animationValue = applyDurationOverride(animationValue, durationOverride)
|
|
618
|
+
}
|
|
619
|
+
|
|
225
620
|
return animationValue ? `${key} ${animationValue}${delayStr}` : null
|
|
226
621
|
})
|
|
227
622
|
.filter(Boolean)
|