@tamagui/animations-css 2.0.0-rc.4 → 2.0.0-rc.40

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.
@@ -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(/(\d+(?:\.\d+)?)\s*ms/)
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(/(\d+(?:\.\d+)?)\s*s/)
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 [onFinish, setOnFinish] = useState<Function | undefined>()
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
- setOnFinish(onFinish)
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,20 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
95
234
  return getStyle(val.getValue())
96
235
  },
97
236
 
98
- useAnimations: ({ props, presence, style, componentState, stateRef }) => {
237
+ useAnimatedNumbersStyle(vals, getStyle) {
238
+ return getStyle(...vals.map((v) => v.getValue()))
239
+ },
240
+
241
+ // @ts-ignore - styleState is added by createComponent
242
+ useAnimations: ({
243
+ props,
244
+ presence,
245
+ style,
246
+ componentState,
247
+ stateRef,
248
+ styleState,
249
+ }: any) => {
250
+ const isHydrating = componentState.unmounted === true
99
251
  const isEntering = !!componentState.unmounted
100
252
  const isExiting = presence?.[0] === false
101
253
  const sendExitComplete = presence?.[1]
@@ -108,8 +260,37 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
108
260
  wasEnteringRef.current = isEntering
109
261
  })
110
262
 
263
+ // exit cycle guards to prevent stale/duplicate completion
264
+ const exitCycleIdRef = React.useRef(0)
265
+ const exitCompletedRef = React.useRef(false)
266
+ const wasExitingRef = React.useRef(false)
267
+ const exitInterruptedRef = React.useRef(false)
268
+
269
+ // detect transition into/out of exiting state
270
+ const justStartedExiting = isExiting && !wasExitingRef.current
271
+ const justStoppedExiting = !isExiting && wasExitingRef.current
272
+
273
+ // start new exit cycle only on transition INTO exiting
274
+ if (justStartedExiting) {
275
+ exitCycleIdRef.current++
276
+ exitCompletedRef.current = false
277
+ }
278
+ // track interruptions so we know to force-restart transitions
279
+ if (justStoppedExiting) {
280
+ exitCycleIdRef.current++
281
+ exitInterruptedRef.current = true
282
+ }
283
+
284
+ // track previous exiting state
285
+ React.useEffect(() => {
286
+ wasExitingRef.current = isExiting
287
+ })
288
+
289
+ // use effectiveTransition computed by createComponent (single source of truth)
290
+ const effectiveTransition = styleState?.effectiveTransition ?? props.transition
291
+
111
292
  // Normalize the transition prop to a consistent format
112
- const normalized = normalizeTransition(props.transition)
293
+ const normalized = normalizeTransition(effectiveTransition)
113
294
 
114
295
  // Determine animation state and get effective animation
115
296
  // Use 'enter' if we're entering OR if we just finished entering (transition is happening)
@@ -155,6 +336,137 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
155
336
  if (!sendExitComplete || !isExiting || !host) return
156
337
  const node = host as HTMLElement
157
338
 
339
+ // capture current cycle id for this effect
340
+ const cycleId = exitCycleIdRef.current
341
+
342
+ // helper to complete exit with guards
343
+ const completeExit = () => {
344
+ if (cycleId !== exitCycleIdRef.current) return
345
+ if (exitCompletedRef.current) return
346
+ exitCompletedRef.current = true
347
+ sendExitComplete()
348
+ }
349
+
350
+ // if no properties to animate (animateOnly=[]), complete immediately
351
+ if (keys.length === 0) {
352
+ completeExit()
353
+ return
354
+ }
355
+
356
+ // Force transition restart for interrupted exits
357
+ // When an exit is interrupted and restarted, the element may already be at
358
+ // the exit style, so no CSS transition fires. We need to:
359
+ // 1. Reset to non-exit state
360
+ // 2. Force reflow
361
+ // 3. Re-apply exit state to trigger transition
362
+ let rafId: number | undefined
363
+ const wasInterrupted = exitInterruptedRef.current
364
+ // flag to ignore transitioncancel during reset (we intentionally cancel the old transition)
365
+ let ignoreCancelEvents = wasInterrupted
366
+ // get enter/exit styles for potential restart
367
+ const enterStyle = props.enterStyle as Record<string, unknown> | undefined
368
+ const exitStyle = props.exitStyle as Record<string, unknown> | undefined
369
+
370
+ // Build the exit transition string - needed for both normal and interrupted exits
371
+ const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''
372
+ const durationOverride = normalized.config?.duration
373
+ const exitTransitionString = keys
374
+ .map((key) => {
375
+ const propAnimation = normalized.properties[key]
376
+ let animationValue: string | null = null
377
+ if (typeof propAnimation === 'string') {
378
+ animationValue = animations[propAnimation]
379
+ } else if (
380
+ propAnimation &&
381
+ typeof propAnimation === 'object' &&
382
+ propAnimation.type
383
+ ) {
384
+ animationValue = animations[propAnimation.type]
385
+ } else if (defaultAnimation) {
386
+ animationValue = defaultAnimation
387
+ }
388
+ if (animationValue && durationOverride) {
389
+ animationValue = applyDurationOverride(animationValue, durationOverride)
390
+ }
391
+ return animationValue ? `${key} ${animationValue}${delayStr}` : null
392
+ })
393
+ .filter(Boolean)
394
+ .join(', ')
395
+
396
+ if (wasInterrupted) {
397
+ exitInterruptedRef.current = false
398
+ // disable transition, reset to enter state
399
+ node.style.transition = 'none'
400
+
401
+ // reset: apply active/open state for each exit property (not enterStyle,
402
+ // which may equal exitStyle — see comment in the normal exit path below)
403
+ if (exitStyle) {
404
+ const resetStyle: Record<string, unknown> = {}
405
+ for (const key of Object.keys(exitStyle)) {
406
+ if (key === 'opacity') {
407
+ resetStyle[key] = 1
408
+ } else if (TRANSFORM_KEYS.includes(key as any)) {
409
+ resetStyle[key] =
410
+ key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
411
+ } else if (enterStyle?.[key] !== undefined) {
412
+ resetStyle[key] = enterStyle[key]
413
+ }
414
+ }
415
+ applyStylesToNode(node, resetStyle)
416
+ } else {
417
+ // fallback if no exitStyle defined
418
+ node.style.opacity = '1'
419
+ node.style.transform = 'none'
420
+ }
421
+
422
+ // force reflow
423
+ void node.offsetHeight
424
+ } else if (exitStyle) {
425
+ // For normal (non-interrupted) exits, we need to ensure the CSS transition is
426
+ // processed by the browser BEFORE the exitStyle takes effect. The issue is that
427
+ // React may have already applied exitStyle in the same render batch. To fix this:
428
+ // 1. Disable transition and reset to non-exit state
429
+ // 2. Force reflow so browser processes the reset
430
+ // 3. Use RAF to ensure we're in a new frame
431
+ // 4. Re-enable transition and apply exitStyle
432
+ // This mirrors the interrupted exit handling approach (which also uses RAF).
433
+ ignoreCancelEvents = true
434
+ node.style.transition = 'none'
435
+
436
+ // Reset to the active/open state (not enterStyle, which may equal exitStyle).
437
+ // enterStyle is the "unmounted" initial state and can share values with exitStyle
438
+ // (e.g., both have opacity: 0). resetting to enterStyle would mean no value change
439
+ // when exitStyle is applied, so the CSS transition wouldn't fire.
440
+ const resetStyle: Record<string, unknown> = {}
441
+ for (const key of Object.keys(exitStyle)) {
442
+ if (key === 'opacity') {
443
+ resetStyle[key] = 1
444
+ } else if (TRANSFORM_KEYS.includes(key as any)) {
445
+ resetStyle[key] =
446
+ key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
447
+ } else if (enterStyle?.[key] !== undefined) {
448
+ resetStyle[key] = enterStyle[key]
449
+ }
450
+ }
451
+ applyStylesToNode(node, resetStyle)
452
+
453
+ // Force reflow
454
+ void node.offsetHeight
455
+
456
+ // Use RAF to ensure transition is applied in a new frame
457
+ rafId = requestAnimationFrame(() => {
458
+ if (cycleId !== exitCycleIdRef.current) return
459
+ // Re-enable transition
460
+ node.style.transition = exitTransitionString
461
+ // Force reflow to ensure transition is active
462
+ void node.offsetHeight
463
+ // Apply exit styles - this triggers the animation
464
+ applyStylesToNode(node, exitStyle)
465
+ // Re-enable cancel event handling
466
+ ignoreCancelEvents = false
467
+ })
468
+ }
469
+
158
470
  /**
159
471
  * Exit animation handling for Dialog/Modal components
160
472
  *
@@ -162,34 +474,115 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
162
474
  * the element can disappear from the DOM before CSS transitions finish, which causes:
163
475
  * 1. Dialogs to stick around on screen
164
476
  * 2. Event handlers to stop working
477
+ *
478
+ * Fix: Calculate the MAXIMUM duration across all animated properties, not just
479
+ * the default. With animateOnly and per-property configs, different properties
480
+ * can have different durations, and we need to wait for the LONGEST one.
165
481
  */
166
482
 
167
- // Use timeout as primary, transition events as backup for reliable exit handling
168
- const animationDuration = defaultAnimation
169
- ? extractDuration(defaultAnimation)
170
- : 200
483
+ // calculate max duration across all animated properties
484
+ let maxDuration = defaultAnimation ? extractDuration(defaultAnimation) : 200
485
+
486
+ // check per-property animation durations using shared helper
487
+ const animationConfigs = getAnimationConfigsForKeys(
488
+ normalized,
489
+ animations as Record<string, string>,
490
+ keys,
491
+ defaultAnimation
492
+ )
493
+ for (const animationValue of animationConfigs.values()) {
494
+ if (animationValue) {
495
+ const duration = extractDuration(animationValue)
496
+ if (duration > maxDuration) {
497
+ maxDuration = duration
498
+ }
499
+ }
500
+ }
501
+
171
502
  const delay = normalized.delay ?? 0
172
- const fallbackTimeout = animationDuration + delay
503
+ const fallbackTimeout = maxDuration + delay
173
504
 
174
505
  const timeoutId = setTimeout(() => {
175
- sendExitComplete?.()
506
+ completeExit()
176
507
  }, fallbackTimeout)
177
508
 
178
- // Listen for transition completion events as backup
179
- const onFinishAnimation = () => {
509
+ // track number of transitioning properties to wait for all to finish
510
+ // (each property fires its own transitionend event)
511
+ const transitioningProps = new Set(keys)
512
+ let completedCount = 0
513
+
514
+ const onFinishAnimation = (event: TransitionEvent) => {
515
+ // only count transitions on THIS element, not bubbled from children
516
+ if (event.target !== node) return
517
+
518
+ // map CSS property names to our key names
519
+ // e.g., transitionend fires with propertyName 'transform' for scale/x/y
520
+ const eventProp = event.propertyName
521
+ if (transitioningProps.has(eventProp) || eventProp === 'all') {
522
+ completedCount++
523
+ // wait for all properties to finish
524
+ if (completedCount >= transitioningProps.size) {
525
+ clearTimeout(timeoutId)
526
+ completeExit()
527
+ }
528
+ }
529
+ }
530
+
531
+ // on cancel, still complete (element is exiting and animation was interrupted)
532
+ // the guards prevent duplicate completion if this is a stale cycle
533
+ const onCancelAnimation = () => {
534
+ // ignore cancel events during reset phase (we intentionally cancel the old transition)
535
+ if (ignoreCancelEvents) return
180
536
  clearTimeout(timeoutId)
181
- sendExitComplete?.()
537
+ completeExit()
182
538
  }
183
539
 
184
540
  node.addEventListener('transitionend', onFinishAnimation)
185
- node.addEventListener('transitioncancel', onFinishAnimation)
541
+ node.addEventListener('transitioncancel', onCancelAnimation)
542
+
543
+ // For interrupted exits, re-enable transition and re-apply exit styles
544
+ // This must happen AFTER listeners are set up so we catch the transitionend
545
+ if (wasInterrupted) {
546
+ rafId = requestAnimationFrame(() => {
547
+ if (cycleId !== exitCycleIdRef.current) return
548
+ // re-enable transition using the pre-built string
549
+ node.style.transition = exitTransitionString
550
+ // force reflow again
551
+ void node.offsetHeight
552
+ // now apply exit styles - this triggers the transition
553
+ applyStylesToNode(node, exitStyle)
554
+ // re-enable cancel event handling now that reset is complete
555
+ ignoreCancelEvents = false
556
+ })
557
+ }
186
558
 
187
559
  return () => {
188
560
  clearTimeout(timeoutId)
561
+ if (rafId !== undefined) cancelAnimationFrame(rafId)
189
562
  node.removeEventListener('transitionend', onFinishAnimation)
190
- node.removeEventListener('transitioncancel', onFinishAnimation)
563
+ node.removeEventListener('transitioncancel', onCancelAnimation)
564
+ // restore transition: the exit handling sets node.style.transition='none'
565
+ // directly on the DOM (bypassing React). if exit is interrupted (e.g. same-key
566
+ // re-entry in AnimatePresence), React won't re-apply its managed transition
567
+ // value because it hasn't changed in the virtual DOM. clearing the inline
568
+ // override lets React's value take effect again.
569
+ node.style.transition = ''
191
570
  }
192
- }, [sendExitComplete, isExiting])
571
+ }, [
572
+ sendExitComplete,
573
+ isExiting,
574
+ stateRef,
575
+ keys,
576
+ normalized,
577
+ defaultAnimation,
578
+ props.enterStyle,
579
+ props.exitStyle,
580
+ ])
581
+
582
+ // tamagui doesnt even use animation output during hydration
583
+ if (isHydrating) {
584
+ return null
585
+ }
193
586
 
194
587
  // Check if we have any animation to apply
195
588
  if (!hasNormalizedAnimation(normalized)) {
@@ -204,6 +597,7 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
204
597
  // TODO: we disabled the transform transition, because it will create issue for inverse function and animate function
205
598
  // for non layout transform properties either use animate function or find a workaround to do it with css
206
599
  const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''
600
+ const durationOverride = normalized.config?.duration
207
601
  style.transition = keys
208
602
  .map((key) => {
209
603
  // Check for property-specific animation, fall back to default
@@ -222,6 +616,11 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
222
616
  animationValue = defaultAnimation
223
617
  }
224
618
 
619
+ // Apply global duration override if specified
620
+ if (animationValue && durationOverride) {
621
+ animationValue = applyDurationOverride(animationValue, durationOverride)
622
+ }
623
+
225
624
  return animationValue ? `${key} ${animationValue}${delayStr}` : null
226
625
  })
227
626
  .filter(Boolean)
@@ -1,11 +1,11 @@
1
1
  {
2
- "mappings": "AAQA,cAAc,uBAAgD;AA0B9D,OAAO,iBAAS,iBAAiB,kBAAkB,YAAY,IAAI,gBAAgB",
2
+ "mappings": "AASA,cAAc,uBAAgD;AAmJ9D,OAAO,iBAAS,iBAAiB,kBAAkB,YAAY,IAAI,gBAAgB",
3
3
  "names": [],
4
4
  "sources": [
5
5
  "src/createAnimations.tsx"
6
6
  ],
7
+ "version": 3,
7
8
  "sourcesContent": [
8
- "import {\n normalizeTransition,\n getAnimatedProperties,\n hasAnimation as hasNormalizedAnimation,\n getEffectiveAnimation,\n} from '@tamagui/animation-helpers'\nimport { useIsomorphicLayoutEffect } from '@tamagui/constants'\nimport { ResetPresence, usePresence } from '@tamagui/use-presence'\nimport type { AnimationDriver, UniversalAnimatedNumber } from '@tamagui/web'\nimport { transformsToString } from '@tamagui/web'\nimport React, { useState } from 'react' // import { animate } from '@tamagui/cubic-bezier-animator'\n\n/**\n * Helper function to extract duration from CSS animation string\n * Examples: \"ease-in 200ms\" -> 200, \"cubic-bezier(0.215, 0.610, 0.355, 1.000) 400ms\" -> 400\n * \"ease-in 0.5s\" -> 500, \"slow 2s\" -> 2000\n */\nfunction extractDuration(animation: string): number {\n // Try to match milliseconds first\n const msMatch = animation.match(/(\\d+(?:\\.\\d+)?)\\s*ms/)\n if (msMatch) {\n return Number.parseInt(msMatch[1], 10)\n }\n\n // Try to match seconds and convert to milliseconds\n const sMatch = animation.match(/(\\d+(?:\\.\\d+)?)\\s*s/)\n if (sMatch) {\n return Math.round(Number.parseFloat(sMatch[1]) * 1000)\n }\n\n // Default to 300ms if no duration found\n return 300\n}\n\nexport function createAnimations<A extends object>(animations: A): AnimationDriver<A> {\n const reactionListeners = new WeakMap<any, Set<Function>>()\n\n return {\n animations,\n usePresence,\n ResetPresence,\n supportsCSS: true,\n inputStyle: 'css',\n outputStyle: 'css',\n classNameAnimation: true,\n\n useAnimatedNumber(initial): UniversalAnimatedNumber<Function> {\n const [val, setVal] = React.useState(initial)\n const [onFinish, setOnFinish] = useState<Function | undefined>()\n\n useIsomorphicLayoutEffect(() => {\n if (onFinish) {\n onFinish?.()\n setOnFinish(undefined)\n }\n }, [onFinish])\n\n return {\n getInstance() {\n return setVal\n },\n getValue() {\n return val\n },\n setValue(next, config, onFinish) {\n setVal(next)\n setOnFinish(onFinish)\n // call reaction listeners with the new value\n const listeners = reactionListeners.get(setVal)\n if (listeners) {\n listeners.forEach((listener) => listener(next))\n }\n },\n stop() {},\n }\n },\n\n useAnimatedNumberReaction({ value }, onValue) {\n React.useEffect(() => {\n const instance = value.getInstance()\n let queue = reactionListeners.get(instance)\n if (!queue) {\n const next = new Set<Function>()\n reactionListeners.set(instance, next)\n queue = next!\n }\n queue.add(onValue)\n return () => {\n queue?.delete(onValue)\n }\n }, [])\n },\n\n useAnimatedNumberStyle(val, getStyle) {\n return getStyle(val.getValue())\n },\n\n useAnimations: ({ props, presence, style, componentState, stateRef }) => {\n const isEntering = !!componentState.unmounted\n const isExiting = presence?.[0] === false\n const sendExitComplete = presence?.[1]\n\n // Track if we just finished entering (transition from entering to not entering)\n // This is needed because the CSS transition happens on the render AFTER t_unmounted is removed\n const wasEnteringRef = React.useRef(isEntering)\n const justFinishedEntering = wasEnteringRef.current && !isEntering\n React.useEffect(() => {\n wasEnteringRef.current = isEntering\n })\n\n // Normalize the transition prop to a consistent format\n const normalized = normalizeTransition(props.transition)\n\n // Determine animation state and get effective animation\n // Use 'enter' if we're entering OR if we just finished entering (transition is happening)\n const animationState = isExiting\n ? 'exit'\n : isEntering || justFinishedEntering\n ? 'enter'\n : 'default'\n const effectiveAnimationKey = getEffectiveAnimation(normalized, animationState)\n const defaultAnimation = effectiveAnimationKey\n ? animations[effectiveAnimationKey]\n : null\n const animatedProperties = getAnimatedProperties(normalized)\n\n // Determine which properties to animate\n // - animateOnly prop is an exclusive filter (only animate those properties)\n // - per-property configs WITHOUT a default = only animate those specific properties\n // - per-property configs WITH a default = per-property overrides + default for rest\n const hasDefault =\n normalized.default !== null ||\n normalized.enter !== null ||\n normalized.exit !== null\n const hasPerPropertyConfigs = animatedProperties.length > 0\n\n let keys: string[]\n if (props.animateOnly) {\n // animateOnly is explicit filter\n keys = props.animateOnly\n } else if (hasPerPropertyConfigs && !hasDefault) {\n // object format without default: { opacity: '200ms' } = only animate opacity\n keys = animatedProperties\n } else if (hasPerPropertyConfigs && hasDefault) {\n // array format or object with default: 'all' first, then per-property overrides\n // CSS transition specificity: later declarations override earlier ones for the same property\n keys = ['all', ...animatedProperties]\n } else {\n // simple string format: 'quick' = animate all\n keys = ['all']\n }\n\n useIsomorphicLayoutEffect(() => {\n const host = stateRef.current.host\n if (!sendExitComplete || !isExiting || !host) return\n const node = host as HTMLElement\n\n /**\n * Exit animation handling for Dialog/Modal components\n *\n * The Challenge: When users close dialogs (via Escape key or clicking outside),\n * the element can disappear from the DOM before CSS transitions finish, which causes:\n * 1. Dialogs to stick around on screen\n * 2. Event handlers to stop working\n */\n\n // Use timeout as primary, transition events as backup for reliable exit handling\n const animationDuration = defaultAnimation\n ? extractDuration(defaultAnimation)\n : 200\n const delay = normalized.delay ?? 0\n const fallbackTimeout = animationDuration + delay\n\n const timeoutId = setTimeout(() => {\n sendExitComplete?.()\n }, fallbackTimeout)\n\n // Listen for transition completion events as backup\n const onFinishAnimation = () => {\n clearTimeout(timeoutId)\n sendExitComplete?.()\n }\n\n node.addEventListener('transitionend', onFinishAnimation)\n node.addEventListener('transitioncancel', onFinishAnimation)\n\n return () => {\n clearTimeout(timeoutId)\n node.removeEventListener('transitionend', onFinishAnimation)\n node.removeEventListener('transitioncancel', onFinishAnimation)\n }\n }, [sendExitComplete, isExiting])\n\n // Check if we have any animation to apply\n if (!hasNormalizedAnimation(normalized)) {\n return null\n }\n\n if (Array.isArray(style.transform)) {\n style.transform = transformsToString(style.transform)\n }\n\n // Build CSS transition string\n // TODO: we disabled the transform transition, because it will create issue for inverse function and animate function\n // for non layout transform properties either use animate function or find a workaround to do it with css\n const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''\n style.transition = keys\n .map((key) => {\n // Check for property-specific animation, fall back to default\n const propAnimation = normalized.properties[key]\n let animationValue: string | null = null\n\n if (typeof propAnimation === 'string') {\n animationValue = animations[propAnimation]\n } else if (\n propAnimation &&\n typeof propAnimation === 'object' &&\n propAnimation.type\n ) {\n animationValue = animations[propAnimation.type]\n } else if (defaultAnimation) {\n animationValue = defaultAnimation\n }\n\n return animationValue ? `${key} ${animationValue}${delayStr}` : null\n })\n .filter(Boolean)\n .join(', ')\n\n if (process.env.NODE_ENV === 'development' && props['debug'] === 'verbose') {\n console.info('CSS animation', {\n props,\n animations,\n normalized,\n defaultAnimation,\n style,\n isEntering,\n isExiting,\n })\n }\n\n return { style, className: isEntering ? 't_unmounted' : '' }\n },\n }\n}\n\n// layout animations\n// useIsomorphicLayoutEffect(() => {\n// if (!host || !props.layout) {\n// return\n// }\n// // @ts-ignore\n// const boundingBox = host?.getBoundingClientRect()\n// if (isChanged(initialPositionRef.current, boundingBox)) {\n// const transform = invert(\n// host,\n// boundingBox,\n// initialPositionRef.current\n// )\n\n// animate({\n// from: transform,\n// to: { x: 0, y: 0, scaleX: 1, scaleY: 1 },\n// duration: 1000,\n// onUpdate: ({ x, y, scaleX, scaleY }) => {\n// // @ts-ignore\n// host.style.transform = `translate(${x}px, ${y}px) scaleX(${scaleX}) scaleY(${scaleY})`\n// // TODO: handle childRef inverse scale\n// // childRef.current.style.transform = `scaleX(${1 / scaleX}) scaleY(${\n// // 1 / scaleY\n// // })`\n// },\n// // TODO: extract ease-in from string and convert/map it to a cubicBezier array\n// cubicBezier: [0, 1.38, 1, -0.41],\n// })\n// }\n// initialPositionRef.current = boundingBox\n// })\n\n// style.transition = `${keys} ${animation}${\n// props.layout ? ',width 0s, height 0s, margin 0s, padding 0s, transform' : ''\n// }`\n\n// const isChanged = (initialBox: any, finalBox: any) => {\n// // we just mounted, so we don't have complete data yet\n// if (!initialBox || !finalBox) return false\n\n// // deep compare the two boxes\n// return JSON.stringify(initialBox) !== JSON.stringify(finalBox)\n// }\n\n// const invert = (el, from, to) => {\n// const { x: fromX, y: fromY, width: fromWidth, height: fromHeight } = from\n// const { x, y, width, height } = to\n\n// const transform = {\n// x: x - fromX - (fromWidth - width) / 2,\n// y: y - fromY - (fromHeight - height) / 2,\n// scaleX: width / fromWidth,\n// scaleY: height / fromHeight,\n// }\n\n// el.style.transform = `\n"
9
- ],
10
- "version": 3
9
+ "import {\n normalizeTransition,\n getAnimatedProperties,\n hasAnimation as hasNormalizedAnimation,\n getEffectiveAnimation,\n getAnimationConfigsForKeys,\n} from '@tamagui/animation-helpers'\nimport { useIsomorphicLayoutEffect } from '@tamagui/constants'\nimport { ResetPresence, usePresence } from '@tamagui/use-presence'\nimport type { AnimationDriver, UniversalAnimatedNumber } from '@tamagui/web'\nimport { transformsToString } from '@tamagui/web'\nimport React, { useState } from 'react' // import { animate } from '@tamagui/cubic-bezier-animator'\n\nconst EXTRACT_MS_REGEX = /(\\d+(?:\\.\\d+)?)\\s*ms/\nconst EXTRACT_S_REGEX = /(\\d+(?:\\.\\d+)?)\\s*s/\n\n/**\n * Helper function to extract duration from CSS animation string\n * Examples: \"ease-in 200ms\" -> 200, \"cubic-bezier(0.215, 0.610, 0.355, 1.000) 400ms\" -> 400\n * \"ease-in 0.5s\" -> 500, \"slow 2s\" -> 2000\n */\nfunction extractDuration(animation: string): number {\n // Try to match milliseconds first\n const msMatch = animation.match(EXTRACT_MS_REGEX)\n if (msMatch) {\n return Number.parseInt(msMatch[1], 10)\n }\n\n // Try to match seconds and convert to milliseconds\n const sMatch = animation.match(EXTRACT_S_REGEX)\n if (sMatch) {\n return Math.round(Number.parseFloat(sMatch[1]) * 1000)\n }\n\n // Default to 300ms if no duration found\n return 300\n}\n\nconst MS_DURATION_REGEX = /(\\d+(?:\\.\\d+)?)\\s*ms/\nconst S_DURATION_REGEX = /(\\d+(?:\\.\\d+)?)\\s*s(?!tiffness)/\n\n/**\n * Apply duration override to a CSS animation string\n * Replaces the existing duration with the override value\n */\nfunction applyDurationOverride(animation: string, durationMs: number): string {\n // Replace ms duration\n const msReplaced = animation.replace(MS_DURATION_REGEX, `${durationMs}ms`)\n if (msReplaced !== animation) {\n return msReplaced\n }\n\n // Replace seconds duration\n const sReplaced = animation.replace(S_DURATION_REGEX, `${durationMs}ms`)\n if (sReplaced !== animation) {\n return sReplaced\n }\n\n // No duration found, prepend the duration\n return `${durationMs}ms ${animation}`\n}\n\n// transform keys that need special handling\nconst TRANSFORM_KEYS = [\n 'x',\n 'y',\n 'scale',\n 'scaleX',\n 'scaleY',\n 'rotate',\n 'rotateX',\n 'rotateY',\n 'rotateZ',\n 'skewX',\n 'skewY',\n] as const\n\n/**\n * Build a CSS transform string from a style object containing transform properties\n */\nfunction buildTransformString(style: Record<string, unknown> | undefined): string {\n if (!style) return ''\n\n const parts: string[] = []\n\n if (style.x !== undefined || style.y !== undefined) {\n const x = style.x ?? 0\n const y = style.y ?? 0\n parts.push(`translate(${x}px, ${y}px)`)\n }\n if (style.scale !== undefined) {\n parts.push(`scale(${style.scale})`)\n }\n if (style.scaleX !== undefined) {\n parts.push(`scaleX(${style.scaleX})`)\n }\n if (style.scaleY !== undefined) {\n parts.push(`scaleY(${style.scaleY})`)\n }\n if (style.rotate !== undefined) {\n const val = style.rotate\n const unit = typeof val === 'string' && val.includes('deg') ? '' : 'deg'\n parts.push(`rotate(${val}${unit})`)\n }\n if (style.rotateX !== undefined) {\n parts.push(`rotateX(${style.rotateX}deg)`)\n }\n if (style.rotateY !== undefined) {\n parts.push(`rotateY(${style.rotateY}deg)`)\n }\n if (style.rotateZ !== undefined) {\n parts.push(`rotateZ(${style.rotateZ}deg)`)\n }\n if (style.skewX !== undefined) {\n parts.push(`skewX(${style.skewX}deg)`)\n }\n if (style.skewY !== undefined) {\n parts.push(`skewY(${style.skewY}deg)`)\n }\n\n return parts.join(' ')\n}\n\n/**\n * Apply a style object to a DOM node, handling transform keys specially\n */\nfunction applyStylesToNode(\n node: HTMLElement,\n style: Record<string, unknown> | undefined\n): void {\n if (!style) return\n\n // collect transform values\n const transformStr = buildTransformString(style)\n if (transformStr) {\n node.style.transform = transformStr\n }\n\n // apply non-transform properties\n for (const [key, value] of Object.entries(style)) {\n if (TRANSFORM_KEYS.includes(key as any)) continue\n if (value === undefined) continue\n\n if (key === 'opacity') {\n node.style.opacity = String(value)\n } else if (key === 'backgroundColor') {\n node.style.backgroundColor = String(value)\n } else if (key === 'color') {\n node.style.color = String(value)\n } else {\n // generic fallback\n node.style[key as any] = typeof value === 'number' ? `${value}px` : String(value)\n }\n }\n}\n\nexport function createAnimations<A extends object>(animations: A): AnimationDriver<A> {\n const reactionListeners = new WeakMap<any, Set<Function>>()\n\n return {\n animations,\n usePresence,\n ResetPresence,\n inputStyle: 'css',\n outputStyle: 'css',\n\n useAnimatedNumber(initial): UniversalAnimatedNumber<Function> {\n const [val, setVal] = React.useState(initial)\n const finishTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n return {\n getInstance() {\n return setVal\n },\n getValue() {\n return val\n },\n setValue(next, config, onFinish) {\n setVal(next)\n\n // clear any pending finish callback from a previous setValue\n if (finishTimerRef.current) {\n clearTimeout(finishTimerRef.current)\n finishTimerRef.current = null\n }\n\n if (onFinish) {\n if (\n !config ||\n config.type === 'direct' ||\n (config.type === 'timing' && config.duration === 0)\n ) {\n onFinish()\n } else {\n // estimate duration: use explicit duration, or fall back to\n // default CSS transition duration for spring-type configs\n const duration = config.type === 'timing' ? config.duration : 300\n finishTimerRef.current = setTimeout(onFinish, duration)\n }\n }\n\n // call reaction listeners with the new value\n const listeners = reactionListeners.get(setVal)\n if (listeners) {\n listeners.forEach((listener) => listener(next))\n }\n },\n stop() {\n if (finishTimerRef.current) {\n clearTimeout(finishTimerRef.current)\n finishTimerRef.current = null\n }\n },\n }\n },\n\n useAnimatedNumberReaction({ value }, onValue) {\n React.useEffect(() => {\n const instance = value.getInstance()\n let queue = reactionListeners.get(instance)\n if (!queue) {\n const next = new Set<Function>()\n reactionListeners.set(instance, next)\n queue = next!\n }\n queue.add(onValue)\n return () => {\n queue?.delete(onValue)\n }\n }, [])\n },\n\n useAnimatedNumberStyle(val, getStyle) {\n return getStyle(val.getValue())\n },\n\n useAnimatedNumbersStyle(vals, getStyle) {\n return getStyle(...vals.map((v) => v.getValue()))\n },\n\n // @ts-ignore - styleState is added by createComponent\n useAnimations: ({\n props,\n presence,\n style,\n componentState,\n stateRef,\n styleState,\n }: any) => {\n const isHydrating = componentState.unmounted === true\n const isEntering = !!componentState.unmounted\n const isExiting = presence?.[0] === false\n const sendExitComplete = presence?.[1]\n\n // Track if we just finished entering (transition from entering to not entering)\n // This is needed because the CSS transition happens on the render AFTER t_unmounted is removed\n const wasEnteringRef = React.useRef(isEntering)\n const justFinishedEntering = wasEnteringRef.current && !isEntering\n React.useEffect(() => {\n wasEnteringRef.current = isEntering\n })\n\n // exit cycle guards to prevent stale/duplicate completion\n const exitCycleIdRef = React.useRef(0)\n const exitCompletedRef = React.useRef(false)\n const wasExitingRef = React.useRef(false)\n const exitInterruptedRef = React.useRef(false)\n\n // detect transition into/out of exiting state\n const justStartedExiting = isExiting && !wasExitingRef.current\n const justStoppedExiting = !isExiting && wasExitingRef.current\n\n // start new exit cycle only on transition INTO exiting\n if (justStartedExiting) {\n exitCycleIdRef.current++\n exitCompletedRef.current = false\n }\n // track interruptions so we know to force-restart transitions\n if (justStoppedExiting) {\n exitCycleIdRef.current++\n exitInterruptedRef.current = true\n }\n\n // track previous exiting state\n React.useEffect(() => {\n wasExitingRef.current = isExiting\n })\n\n // use effectiveTransition computed by createComponent (single source of truth)\n const effectiveTransition = styleState?.effectiveTransition ?? props.transition\n\n // Normalize the transition prop to a consistent format\n const normalized = normalizeTransition(effectiveTransition)\n\n // Determine animation state and get effective animation\n // Use 'enter' if we're entering OR if we just finished entering (transition is happening)\n const animationState = isExiting\n ? 'exit'\n : isEntering || justFinishedEntering\n ? 'enter'\n : 'default'\n const effectiveAnimationKey = getEffectiveAnimation(normalized, animationState)\n const defaultAnimation = effectiveAnimationKey\n ? animations[effectiveAnimationKey]\n : null\n const animatedProperties = getAnimatedProperties(normalized)\n\n // Determine which properties to animate\n // - animateOnly prop is an exclusive filter (only animate those properties)\n // - per-property configs WITHOUT a default = only animate those specific properties\n // - per-property configs WITH a default = per-property overrides + default for rest\n const hasDefault =\n normalized.default !== null ||\n normalized.enter !== null ||\n normalized.exit !== null\n const hasPerPropertyConfigs = animatedProperties.length > 0\n\n let keys: string[]\n if (props.animateOnly) {\n // animateOnly is explicit filter\n keys = props.animateOnly\n } else if (hasPerPropertyConfigs && !hasDefault) {\n // object format without default: { opacity: '200ms' } = only animate opacity\n keys = animatedProperties\n } else if (hasPerPropertyConfigs && hasDefault) {\n // array format or object with default: 'all' first, then per-property overrides\n // CSS transition specificity: later declarations override earlier ones for the same property\n keys = ['all', ...animatedProperties]\n } else {\n // simple string format: 'quick' = animate all\n keys = ['all']\n }\n\n useIsomorphicLayoutEffect(() => {\n const host = stateRef.current.host\n if (!sendExitComplete || !isExiting || !host) return\n const node = host as HTMLElement\n\n // capture current cycle id for this effect\n const cycleId = exitCycleIdRef.current\n\n // helper to complete exit with guards\n const completeExit = () => {\n if (cycleId !== exitCycleIdRef.current) return\n if (exitCompletedRef.current) return\n exitCompletedRef.current = true\n sendExitComplete()\n }\n\n // if no properties to animate (animateOnly=[]), complete immediately\n if (keys.length === 0) {\n completeExit()\n return\n }\n\n // Force transition restart for interrupted exits\n // When an exit is interrupted and restarted, the element may already be at\n // the exit style, so no CSS transition fires. We need to:\n // 1. Reset to non-exit state\n // 2. Force reflow\n // 3. Re-apply exit state to trigger transition\n let rafId: number | undefined\n const wasInterrupted = exitInterruptedRef.current\n // flag to ignore transitioncancel during reset (we intentionally cancel the old transition)\n let ignoreCancelEvents = wasInterrupted\n // get enter/exit styles for potential restart\n const enterStyle = props.enterStyle as Record<string, unknown> | undefined\n const exitStyle = props.exitStyle as Record<string, unknown> | undefined\n\n // Build the exit transition string - needed for both normal and interrupted exits\n const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''\n const durationOverride = normalized.config?.duration\n const exitTransitionString = keys\n .map((key) => {\n const propAnimation = normalized.properties[key]\n let animationValue: string | null = null\n if (typeof propAnimation === 'string') {\n animationValue = animations[propAnimation]\n } else if (\n propAnimation &&\n typeof propAnimation === 'object' &&\n propAnimation.type\n ) {\n animationValue = animations[propAnimation.type]\n } else if (defaultAnimation) {\n animationValue = defaultAnimation\n }\n if (animationValue && durationOverride) {\n animationValue = applyDurationOverride(animationValue, durationOverride)\n }\n return animationValue ? `${key} ${animationValue}${delayStr}` : null\n })\n .filter(Boolean)\n .join(', ')\n\n if (wasInterrupted) {\n exitInterruptedRef.current = false\n // disable transition, reset to enter state\n node.style.transition = 'none'\n\n // reset: apply active/open state for each exit property (not enterStyle,\n // which may equal exitStyle — see comment in the normal exit path below)\n if (exitStyle) {\n const resetStyle: Record<string, unknown> = {}\n for (const key of Object.keys(exitStyle)) {\n if (key === 'opacity') {\n resetStyle[key] = 1\n } else if (TRANSFORM_KEYS.includes(key as any)) {\n resetStyle[key] =\n key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0\n } else if (enterStyle?.[key] !== undefined) {\n resetStyle[key] = enterStyle[key]\n }\n }\n applyStylesToNode(node, resetStyle)\n } else {\n // fallback if no exitStyle defined\n node.style.opacity = '1'\n node.style.transform = 'none'\n }\n\n // force reflow\n void node.offsetHeight\n } else if (exitStyle) {\n // For normal (non-interrupted) exits, we need to ensure the CSS transition is\n // processed by the browser BEFORE the exitStyle takes effect. The issue is that\n // React may have already applied exitStyle in the same render batch. To fix this:\n // 1. Disable transition and reset to non-exit state\n // 2. Force reflow so browser processes the reset\n // 3. Use RAF to ensure we're in a new frame\n // 4. Re-enable transition and apply exitStyle\n // This mirrors the interrupted exit handling approach (which also uses RAF).\n ignoreCancelEvents = true\n node.style.transition = 'none'\n\n // Reset to the active/open state (not enterStyle, which may equal exitStyle).\n // enterStyle is the \"unmounted\" initial state and can share values with exitStyle\n // (e.g., both have opacity: 0). resetting to enterStyle would mean no value change\n // when exitStyle is applied, so the CSS transition wouldn't fire.\n const resetStyle: Record<string, unknown> = {}\n for (const key of Object.keys(exitStyle)) {\n if (key === 'opacity') {\n resetStyle[key] = 1\n } else if (TRANSFORM_KEYS.includes(key as any)) {\n resetStyle[key] =\n key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0\n } else if (enterStyle?.[key] !== undefined) {\n resetStyle[key] = enterStyle[key]\n }\n }\n applyStylesToNode(node, resetStyle)\n\n // Force reflow\n void node.offsetHeight\n\n // Use RAF to ensure transition is applied in a new frame\n rafId = requestAnimationFrame(() => {\n if (cycleId !== exitCycleIdRef.current) return\n // Re-enable transition\n node.style.transition = exitTransitionString\n // Force reflow to ensure transition is active\n void node.offsetHeight\n // Apply exit styles - this triggers the animation\n applyStylesToNode(node, exitStyle)\n // Re-enable cancel event handling\n ignoreCancelEvents = false\n })\n }\n\n /**\n * Exit animation handling for Dialog/Modal components\n *\n * The Challenge: When users close dialogs (via Escape key or clicking outside),\n * the element can disappear from the DOM before CSS transitions finish, which causes:\n * 1. Dialogs to stick around on screen\n * 2. Event handlers to stop working\n *\n * Fix: Calculate the MAXIMUM duration across all animated properties, not just\n * the default. With animateOnly and per-property configs, different properties\n * can have different durations, and we need to wait for the LONGEST one.\n */\n\n // calculate max duration across all animated properties\n let maxDuration = defaultAnimation ? extractDuration(defaultAnimation) : 200\n\n // check per-property animation durations using shared helper\n const animationConfigs = getAnimationConfigsForKeys(\n normalized,\n animations as Record<string, string>,\n keys,\n defaultAnimation\n )\n for (const animationValue of animationConfigs.values()) {\n if (animationValue) {\n const duration = extractDuration(animationValue)\n if (duration > maxDuration) {\n maxDuration = duration\n }\n }\n }\n\n const delay = normalized.delay ?? 0\n const fallbackTimeout = maxDuration + delay\n\n const timeoutId = setTimeout(() => {\n completeExit()\n }, fallbackTimeout)\n\n // track number of transitioning properties to wait for all to finish\n // (each property fires its own transitionend event)\n const transitioningProps = new Set(keys)\n let completedCount = 0\n\n const onFinishAnimation = (event: TransitionEvent) => {\n // only count transitions on THIS element, not bubbled from children\n if (event.target !== node) return\n\n // map CSS property names to our key names\n // e.g., transitionend fires with propertyName 'transform' for scale/x/y\n const eventProp = event.propertyName\n if (transitioningProps.has(eventProp) || eventProp === 'all') {\n completedCount++\n // wait for all properties to finish\n if (completedCount >= transitioningProps.size) {\n clearTimeout(timeoutId)\n completeExit()\n }\n }\n }\n\n // on cancel, still complete (element is exiting and animation was interrupted)\n // the guards prevent duplicate completion if this is a stale cycle\n const onCancelAnimation = () => {\n // ignore cancel events during reset phase (we intentionally cancel the old transition)\n if (ignoreCancelEvents) return\n clearTimeout(timeoutId)\n completeExit()\n }\n\n node.addEventListener('transitionend', onFinishAnimation)\n node.addEventListener('transitioncancel', onCancelAnimation)\n\n // For interrupted exits, re-enable transition and re-apply exit styles\n // This must happen AFTER listeners are set up so we catch the transitionend\n if (wasInterrupted) {\n rafId = requestAnimationFrame(() => {\n if (cycleId !== exitCycleIdRef.current) return\n // re-enable transition using the pre-built string\n node.style.transition = exitTransitionString\n // force reflow again\n void node.offsetHeight\n // now apply exit styles - this triggers the transition\n applyStylesToNode(node, exitStyle)\n // re-enable cancel event handling now that reset is complete\n ignoreCancelEvents = false\n })\n }\n\n return () => {\n clearTimeout(timeoutId)\n if (rafId !== undefined) cancelAnimationFrame(rafId)\n node.removeEventListener('transitionend', onFinishAnimation)\n node.removeEventListener('transitioncancel', onCancelAnimation)\n // restore transition: the exit handling sets node.style.transition='none'\n // directly on the DOM (bypassing React). if exit is interrupted (e.g. same-key\n // re-entry in AnimatePresence), React won't re-apply its managed transition\n // value because it hasn't changed in the virtual DOM. clearing the inline\n // override lets React's value take effect again.\n node.style.transition = ''\n }\n }, [\n sendExitComplete,\n isExiting,\n stateRef,\n keys,\n normalized,\n defaultAnimation,\n props.enterStyle,\n props.exitStyle,\n ])\n\n // tamagui doesnt even use animation output during hydration\n if (isHydrating) {\n return null\n }\n\n // Check if we have any animation to apply\n if (!hasNormalizedAnimation(normalized)) {\n return null\n }\n\n if (Array.isArray(style.transform)) {\n style.transform = transformsToString(style.transform)\n }\n\n // Build CSS transition string\n // TODO: we disabled the transform transition, because it will create issue for inverse function and animate function\n // for non layout transform properties either use animate function or find a workaround to do it with css\n const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''\n const durationOverride = normalized.config?.duration\n style.transition = keys\n .map((key) => {\n // Check for property-specific animation, fall back to default\n const propAnimation = normalized.properties[key]\n let animationValue: string | null = null\n\n if (typeof propAnimation === 'string') {\n animationValue = animations[propAnimation]\n } else if (\n propAnimation &&\n typeof propAnimation === 'object' &&\n propAnimation.type\n ) {\n animationValue = animations[propAnimation.type]\n } else if (defaultAnimation) {\n animationValue = defaultAnimation\n }\n\n // Apply global duration override if specified\n if (animationValue && durationOverride) {\n animationValue = applyDurationOverride(animationValue, durationOverride)\n }\n\n return animationValue ? `${key} ${animationValue}${delayStr}` : null\n })\n .filter(Boolean)\n .join(', ')\n\n if (process.env.NODE_ENV === 'development' && props['debug'] === 'verbose') {\n console.info('CSS animation', {\n props,\n animations,\n normalized,\n defaultAnimation,\n style,\n isEntering,\n isExiting,\n })\n }\n\n return { style, className: isEntering ? 't_unmounted' : '' }\n },\n }\n}\n\n// layout animations\n// useIsomorphicLayoutEffect(() => {\n// if (!host || !props.layout) {\n// return\n// }\n// // @ts-ignore\n// const boundingBox = host?.getBoundingClientRect()\n// if (isChanged(initialPositionRef.current, boundingBox)) {\n// const transform = invert(\n// host,\n// boundingBox,\n// initialPositionRef.current\n// )\n\n// animate({\n// from: transform,\n// to: { x: 0, y: 0, scaleX: 1, scaleY: 1 },\n// duration: 1000,\n// onUpdate: ({ x, y, scaleX, scaleY }) => {\n// // @ts-ignore\n// host.style.transform = `translate(${x}px, ${y}px) scaleX(${scaleX}) scaleY(${scaleY})`\n// // TODO: handle childRef inverse scale\n// // childRef.current.style.transform = `scaleX(${1 / scaleX}) scaleY(${\n// // 1 / scaleY\n// // })`\n// },\n// // TODO: extract ease-in from string and convert/map it to a cubicBezier array\n// cubicBezier: [0, 1.38, 1, -0.41],\n// })\n// }\n// initialPositionRef.current = boundingBox\n// })\n\n// style.transition = `${keys} ${animation}${\n// props.layout ? ',width 0s, height 0s, margin 0s, padding 0s, transform' : ''\n// }`\n\n// const isChanged = (initialBox: any, finalBox: any) => {\n// // we just mounted, so we don't have complete data yet\n// if (!initialBox || !finalBox) return false\n\n// // deep compare the two boxes\n// return JSON.stringify(initialBox) !== JSON.stringify(finalBox)\n// }\n\n// const invert = (el, from, to) => {\n// const { x: fromX, y: fromY, width: fromWidth, height: fromHeight } = from\n// const { x, y, width, height } = to\n\n// const transform = {\n// x: x - fromX - (fromWidth - width) / 2,\n// y: y - fromY - (fromHeight - height) / 2,\n// scaleX: width / fromWidth,\n// scaleY: height / fromHeight,\n// }\n\n// el.style.transform = `\n"
10
+ ]
11
11
  }