Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support CSS transitions in RAC #7488

Merged
merged 10 commits into from
Jan 14, 2025
Prev Previous commit
Next Next commit
Merge branch 'main' of github.com:adobe/react-spectrum into rac-css-t…
…ransition

# Conflicts:
#	packages/react-aria-components/src/utils.tsx
  • Loading branch information
devongovett committed Jan 9, 2025
commit 68c9119fd1a428c6c909ea6e86e4effd0f844227
71 changes: 43 additions & 28 deletions packages/@react-aria/utils/src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,31 @@
*/

import {flushSync} from 'react-dom';
import {RefObject, useCallback, useRef, useState} from 'react';
import {RefObject, useCallback, useState} from 'react';
import {useLayoutEffect} from './useLayoutEffect';

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) {
Expand Down Expand Up @@ -51,35 +69,32 @@ 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);
flushSync(() => {onEnd();});
}
};

let element = ref.current;
element.addEventListener('animationend', onAnimationEnd);
return () => {
element.removeEventListener('animationend', onAnimationEnd);
};
} else {
if (!('getAnimations' in ref.current)) {
// JSDOM
onEnd();
return;
}

let animations = ref.current.getAnimations();
if (animations.length === 0) {
onEnd();
return;
}

let canceled = false;
Promise.all(animations.map(a => a.finished)).then(() => {
if (!canceled) {
flushSync(() => {
onEnd();
});
}
}).catch(() => {});

return () => {
canceled = true;
};
}
}, [ref, isActive, onEnd]);
}
85 changes: 0 additions & 85 deletions packages/react-aria-components/src/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,91 +231,6 @@ export function useSlot(): [RefCallback<Element>, boolean] {
return [ref, hasSlot];
}

export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: boolean = true) {
let [isEntering, setEntering] = useState(true);
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) {
// State to trigger a re-render after animation is complete, which causes the element to be removed from the DOM.
// Ref to track the state we're in, so we don't immediately reset isExiting to true after the animation.
let [isExiting, setExiting] = useState(false);
let [exitState, setExitState] = useState('idle');

// If isOpen becomes false, set isExiting to true.
if (!isOpen && ref.current && exitState === 'idle') {
isExiting = true;
setExiting(true);
setExitState('exiting');
}

// If we exited, and the element has been removed, reset exit state to idle.
if (!ref.current && exitState === 'exited') {
setExitState('idle');
}

useAnimation(
ref,
isExiting,
useCallback(() => {
setExitState('exited');
setExiting(false);
}, [])
);

return isExiting;
}

function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onEnd: () => void) {
useLayoutEffect(() => {
if (isActive && ref.current) {
if (!('getAnimations' in ref.current)) {
// JSDOM
onEnd();
return;
}

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]);
}

/**
* Filters out `data-*` attributes to keep them from being passed down and duplicated.
* @param props
Expand Down
You are viewing a condensed version of this merge commit. You can view the full changes here.