From fa5755c38a55c9dfc9d85797836d612468bd1c75 Mon Sep 17 00:00:00 2001
From: Devon Govett <devongovett@gmail.com>
Date: Thu, 5 Dec 2024 22:34:22 -0800
Subject: [PATCH] feat: Support CSS transitions in RAC

---
 packages/@react-spectrum/s2/src/Modal.tsx    |  61 +++-------
 packages/@react-spectrum/s2/src/Popover.tsx  | 113 +++++--------------
 packages/react-aria-components/src/utils.tsx |  65 ++++++-----
 3 files changed, 80 insertions(+), 159 deletions(-)

diff --git a/packages/@react-spectrum/s2/src/Modal.tsx b/packages/@react-spectrum/s2/src/Modal.tsx
index 1d5423b79ba..b8b9350a38c 100644
--- a/packages/@react-spectrum/s2/src/Modal.tsx
+++ b/packages/@react-spectrum/s2/src/Modal.tsx
@@ -14,7 +14,6 @@ import {colorScheme} from './style-utils' with {type: 'macro'};
 import {ColorSchemeContext} from './Provider';
 import {DOMRef} from '@react-types/shared';
 import {forwardRef, MutableRefObject, useCallback, useContext} from 'react';
-import {keyframes} from '../style/style-macro' with {type: 'macro'};
 import {ModalOverlay, ModalOverlayProps, Modal as RACModal, useLocale} from 'react-aria-components';
 import {style} from '../style' with {type: 'macro'};
 import {useDOMRef} from '@react-spectrum/utils';
@@ -28,28 +27,6 @@ interface ModalProps extends ModalOverlayProps {
   size?: 'S' | 'M' | 'L' | 'fullscreen' | 'fullscreenTakeover'
 }
 
-const fade = keyframes(`
-  from {
-    opacity: 0;
-  }
-
-  to {
-    opacity: 1;
-  }
-`);
-
-const fadeAndSlide = keyframes(`
-  from {
-    opacity: 0;
-    transform: translateY(20px);
-  }
-
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
-`);
-
 const modalOverlayStyles = style({
   ...colorScheme(),
   position: 'fixed',
@@ -59,17 +36,14 @@ const modalOverlayStyles = style({
   display: 'flex',
   alignItems: 'center',
   justifyContent: 'center',
-  animation: {
-    isEntering: fade,
-    isExiting: fade
+  opacity: {
+    isEntering: 0,
+    isExiting: 0
   },
-  animationDuration: {
-    isEntering: 250,
+  transition: 'opacity',
+  transitionDuration: {
+    default: 250,
     isExiting: 130
-  },
-  animationDirection: {
-    isEntering: 'normal',
-    isExiting: 'reverse'
   }
 });
 
@@ -141,23 +115,22 @@ export const Modal = forwardRef(function Modal(props: ModalProps, ref: DOMRef<HT
             value: 'layer-2'
           },
           backgroundColor: '--s2-container-bg',
-          animation: {
-            isEntering: fadeAndSlide,
-            isExiting: fade
+          opacity: {
+            isEntering: 0,
+            isExiting: 0
+          },
+          translateY: {
+            isEntering: 20
           },
-          animationDuration: {
-            isEntering: 250,
+          transition: '[opacity, translate]',
+          transitionDuration: {
+            default: 250,
             isExiting: 130
           },
-          animationDelay: {
-            isEntering: 160,
+          transitionDelay: {
+            default: 160,
             isExiting: 0
           },
-          animationDirection: {
-            isEntering: 'normal',
-            isExiting: 'reverse'
-          },
-          animationFillMode: 'both',
           // Transparent outline for WHCM.
           outlineStyle: 'solid',
           outlineWidth: 1,
diff --git a/packages/@react-spectrum/s2/src/Popover.tsx b/packages/@react-spectrum/s2/src/Popover.tsx
index b0a7f4b71de..e52ae49e24f 100644
--- a/packages/@react-spectrum/s2/src/Popover.tsx
+++ b/packages/@react-spectrum/s2/src/Popover.tsx
@@ -24,7 +24,6 @@ import {colorScheme, getAllowedOverrides, StyleProps, UnsafeStyles} from './styl
 import {ColorSchemeContext} from './Provider';
 import {DOMRef} from '@react-types/shared';
 import {forwardRef, MutableRefObject, useCallback, useContext} from 'react';
-import {keyframes} from '../style/style-macro' with {type: 'macro'};
 import {mergeStyles} from '../style/runtime';
 import {style} from '../style' with {type: 'macro'};
 import {StyleString} from '../style/types' with {type: 'macro'};
@@ -46,52 +45,6 @@ export interface PopoverProps extends UnsafeStyles, Omit<AriaPopoverProps, 'arro
   // mobileType?: 'modal' | 'fullscreen' | 'fullscreenTakeover' // TODO: add tray back in
 }
 
-const fadeKeyframes = keyframes(`
-  from {
-    opacity: 0;
-  }
-
-  to {
-    opacity: 1;
-  }
-`);
-const slideUpKeyframes = keyframes(`
-  from {
-    transform: translateY(-4px);
-  }
-
-  to {
-    transform: translateY(0);
-  }
-`);
-const slideDownKeyframes = keyframes(`
-  from {
-    transform: translateY(4px);
-  }
-
-  to {
-    transform: translateY(0);
-  }
-`);
-const slideRightKeyframes = keyframes(`
-  from {
-    transform: translateX(4px);
-  }
-
-  to {
-    transform: translateX(0);
-  }
-`);
-const slideLeftKeyframes = keyframes(`
-  from {
-    transform: translateX(-4px);
-  }
-
-  to {
-    transform: translateX(0);
-  }
-`);
-
 let popover = style({
   ...colorScheme(),
   '--s2-container-bg': {
@@ -126,63 +79,48 @@ let popover = style({
   // Don't be larger than full screen minus 2 * containerPadding
   maxWidth: '[calc(100vw - 24px)]',
   boxSizing: 'border-box',
+  opacity: {
+    isEntering: 0,
+    isExiting: 0
+  },
   translateY: {
     placement: {
-      bottom: {
-        isArrowShown: 8 // TODO: not defined yet should this change with font size? need boolean support for 'hideArrow' prop
-      },
       top: {
-        isArrowShown: -8
+        isEntering: 4,
+        isExiting: 4
+      },
+      bottom: {
+        isEntering: -4,
+        isExiting: -4
       }
-    }
+    },
+    isSubmenu: 0
   },
   translateX: {
     placement: {
       left: {
-        isArrowShown: -8
+        isEntering: 4,
+        isExiting: 4
       },
       right: {
-        isArrowShown: 8
+        isEntering: -4,
+        isExiting: -4
       }
-    }
+    },
+    isSubmenu: 0
   },
-  animation: {
+  transition: {
     placement: {
-      top: {
-        isEntering: `${slideDownKeyframes}, ${fadeKeyframes}`,
-        isExiting: `${slideDownKeyframes}, ${fadeKeyframes}`
-      },
-      bottom: {
-        isEntering: `${slideUpKeyframes}, ${fadeKeyframes}`,
-        isExiting: `${slideUpKeyframes}, ${fadeKeyframes}`
-      },
-      left: {
-        isEntering: `${slideRightKeyframes}, ${fadeKeyframes}`,
-        isExiting: `${slideRightKeyframes}, ${fadeKeyframes}`
-      },
-      right: {
-        isEntering: `${slideLeftKeyframes}, ${fadeKeyframes}`,
-        isExiting: `${slideLeftKeyframes}, ${fadeKeyframes}`
-      }
-    },
-    isSubmenu: {
-      isEntering: fadeKeyframes,
-      isExiting: fadeKeyframes
+      left: '[opacity, translate]',
+      right: '[opacity, translate]',
+      top: '[opacity, translate]',
+      bottom: '[opacity, translate]'
     }
   },
-  animationDuration: {
-    isEntering: 200,
-    isExiting: 200
-  },
-  animationDirection: {
-    isEntering: 'normal',
-    isExiting: 'reverse'
-  },
-  animationTimingFunction: {
+  transitionDuration: 200,
+  transitionTimingFunction: {
     isExiting: 'in'
   },
-  transition: '[opacity, transform]',
-  willChange: '[opacity, transform]',
   isolation: 'isolate',
   pointerEvents: {
     isExiting: 'none'
@@ -262,6 +200,7 @@ export const PopoverBase = forwardRef(function PopoverBase(props: PopoverProps,
   return (
     <AriaPopover
       {...props}
+      offset={(props.offset ?? 8) + (hideArrow ? 0 : 8)}
       ref={popoverRef}
       style={{
         ...UNSAFE_style,
diff --git a/packages/react-aria-components/src/utils.tsx b/packages/react-aria-components/src/utils.tsx
index 97c686fb243..8d29f5d9f5f 100644
--- a/packages/react-aria-components/src/utils.tsx
+++ b/packages/react-aria-components/src/utils.tsx
@@ -234,8 +234,26 @@ export function useSlot(): [RefCallback<Element>, boolean] {
 
 export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: boolean = true) {
   let [isEntering, setEntering] = useState(true);
-  useAnimation(ref, isEntering && isReady, useCallback(() => setEntering(false), []));
-  return isEntering && isReady;
+  let isAnimationReady = isEntering && isReady;
+  
+  // There are two cases for entry animations:
+  // 1. CSS @keyframes. The `animation` property is set during the isEntering state, and it is removed after the animation finishes.
+  // 2. CSS transitions. The initial styles are applied during the isEntering state, and removed immediately, causing the transition to occur.
+  //
+  // In the second case, cancel any transitions that were triggered prior to the isEntering = false state (when the transition is supposed to start).
+  // This can happen when isReady starts as false (e.g. popovers prior to placement calculation).
+  useLayoutEffect(() => {
+    if (isAnimationReady && ref.current && 'getAnimations' in ref.current) {
+      for (let animation of ref.current.getAnimations()) {
+        if (animation instanceof CSSTransition) {
+          animation.cancel();
+        }
+      }
+    }
+  }, [ref, isAnimationReady]);
+
+  useAnimation(ref, isAnimationReady, useCallback(() => setEntering(false), []));
+  return isAnimationReady;
 }
 
 export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boolean) {
@@ -269,35 +287,26 @@ export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boo
 }
 
 function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onEnd: () => void) {
-  let prevAnimation = useRef<string | null>(null);
-  if (isActive && ref.current) {
-    // This is ok because we only read it in the layout effect below, immediately after the commit phase.
-    // We could move this to another effect that runs every render, but this would be unnecessarily slow.
-    // We only need the computed style right before the animation becomes active.
-    // eslint-disable-next-line rulesdir/pure-render
-    prevAnimation.current = window.getComputedStyle(ref.current).animation;
-  }
-
   useLayoutEffect(() => {
-    if (isActive && ref.current) {
-      // Make sure there's actually an animation, and it wasn't there before we triggered the update.
-      let computedStyle = window.getComputedStyle(ref.current);
-      if (computedStyle.animationName && computedStyle.animationName !== 'none' && computedStyle.animation !== prevAnimation.current) {
-        let onAnimationEnd = (e: AnimationEvent) => {
-          if (e.target === ref.current) {
-            element.removeEventListener('animationend', onAnimationEnd);
-            ReactDOM.flushSync(() => {onEnd();});
-          }
-        };
-
-        let element = ref.current;
-        element.addEventListener('animationend', onAnimationEnd);
-        return () => {
-          element.removeEventListener('animationend', onAnimationEnd);
-        };
-      } else {
+    if (isActive && ref.current && 'getAnimations' in ref.current) {
+      let animations = ref.current.getAnimations();
+      if (animations.length === 0) {
         onEnd();
+        return;
       }
+
+      let canceled = false;
+      Promise.all(animations.map(a => a.finished)).then(() => {
+        if (!canceled) {
+          ReactDOM.flushSync(() => {
+            onEnd();
+          });
+        }
+      }).catch(() => {});
+      
+      return () => {
+        canceled = true;
+      };
     }
   }, [ref, isActive, onEnd]);
 }