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