Skip to content

Commit

Permalink
feat: Support CSS transitions in RAC
Browse files Browse the repository at this point in the history
  • Loading branch information
devongovett committed Dec 6, 2024
1 parent f90799b commit fa5755c
Show file tree
Hide file tree
Showing 3 changed files with 80 additions and 159 deletions.
61 changes: 17 additions & 44 deletions packages/@react-spectrum/s2/src/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand All @@ -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'
}
});

Expand Down Expand Up @@ -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,
Expand Down
113 changes: 26 additions & 87 deletions packages/@react-spectrum/s2/src/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'};
Expand All @@ -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': {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 37 additions & 28 deletions packages/react-aria-components/src/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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]);
}
Expand Down

0 comments on commit fa5755c

Please sign in to comment.