Skip to content

Commit 1b425ca

Browse files
feat: Support CSS transitions in RAC (#7488)
* feat: Support CSS transitions in RAC * jsdom * Fix interrupting * Update ActionBar and Tooltip * Update docs * Remove alpha badge from RAC * fix comments * review updates --------- Co-authored-by: Robert Snow <[email protected]>
1 parent 32a9a54 commit 1b425ca

File tree

10 files changed

+213
-301
lines changed

10 files changed

+213
-301
lines changed

packages/@react-aria/utils/src/animation.ts

Lines changed: 62 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,75 +11,93 @@
1111
*/
1212

1313
import {flushSync} from 'react-dom';
14-
import {RefObject, useCallback, useRef, useState} from 'react';
14+
import {RefObject, useCallback, useState} from 'react';
1515
import {useLayoutEffect} from './useLayoutEffect';
1616

1717
export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: boolean = true) {
1818
let [isEntering, setEntering] = useState(true);
19-
useAnimation(ref, isEntering && isReady, useCallback(() => setEntering(false), []));
20-
return isEntering && isReady;
19+
let isAnimationReady = isEntering && isReady;
20+
21+
// There are two cases for entry animations:
22+
// 1. CSS @keyframes. The `animation` property is set during the isEntering state, and it is removed after the animation finishes.
23+
// 2. CSS transitions. The initial styles are applied during the isEntering state, and removed immediately, causing the transition to occur.
24+
//
25+
// In the second case, cancel any transitions that were triggered prior to the isEntering = false state (when the transition is supposed to start).
26+
// This can happen when isReady starts as false (e.g. popovers prior to placement calculation).
27+
useLayoutEffect(() => {
28+
if (isAnimationReady && ref.current && 'getAnimations' in ref.current) {
29+
for (let animation of ref.current.getAnimations()) {
30+
if (animation instanceof CSSTransition) {
31+
animation.cancel();
32+
}
33+
}
34+
}
35+
}, [ref, isAnimationReady]);
36+
37+
useAnimation(ref, isAnimationReady, useCallback(() => setEntering(false), []));
38+
return isAnimationReady;
2139
}
2240

2341
export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boolean) {
24-
// State to trigger a re-render after animation is complete, which causes the element to be removed from the DOM.
25-
// Ref to track the state we're in, so we don't immediately reset isExiting to true after the animation.
26-
let [isExiting, setExiting] = useState(false);
27-
let [exitState, setExitState] = useState('idle');
42+
let [exitState, setExitState] = useState<'closed' | 'open' | 'exiting'>(isOpen ? 'open' : 'closed');
2843

29-
// If isOpen becomes false, set isExiting to true.
30-
if (!isOpen && ref.current && exitState === 'idle') {
31-
isExiting = true;
32-
setExiting(true);
33-
setExitState('exiting');
34-
}
35-
36-
// If we exited, and the element has been removed, reset exit state to idle.
37-
if (!ref.current && exitState === 'exited') {
38-
setExitState('idle');
44+
switch (exitState) {
45+
case 'open':
46+
// If isOpen becomes false, set the state to exiting.
47+
if (!isOpen) {
48+
setExitState('exiting');
49+
}
50+
break;
51+
case 'closed':
52+
case 'exiting':
53+
// If we are exiting and isOpen becomes true, the animation was interrupted.
54+
// Reset the state to open.
55+
if (isOpen) {
56+
setExitState('open');
57+
}
58+
break;
3959
}
4060

61+
let isExiting = exitState === 'exiting';
4162
useAnimation(
4263
ref,
4364
isExiting,
4465
useCallback(() => {
45-
setExitState('exited');
46-
setExiting(false);
66+
// Set the state to closed, which will cause the element to be unmounted.
67+
setExitState(state => state === 'exiting' ? 'closed' : state);
4768
}, [])
4869
);
4970

5071
return isExiting;
5172
}
5273

5374
function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onEnd: () => void) {
54-
let prevAnimation = useRef<string | null>(null);
55-
if (isActive && ref.current) {
56-
// This is ok because we only read it in the layout effect below, immediately after the commit phase.
57-
// We could move this to another effect that runs every render, but this would be unnecessarily slow.
58-
// We only need the computed style right before the animation becomes active.
59-
// eslint-disable-next-line rulesdir/pure-render
60-
prevAnimation.current = window.getComputedStyle(ref.current).animation;
61-
}
62-
6375
useLayoutEffect(() => {
6476
if (isActive && ref.current) {
65-
// Make sure there's actually an animation, and it wasn't there before we triggered the update.
66-
let computedStyle = window.getComputedStyle(ref.current);
67-
if (computedStyle.animationName && computedStyle.animationName !== 'none' && computedStyle.animation !== prevAnimation.current) {
68-
let onAnimationEnd = (e: AnimationEvent) => {
69-
if (e.target === ref.current) {
70-
element.removeEventListener('animationend', onAnimationEnd);
71-
flushSync(() => {onEnd();});
72-
}
73-
};
74-
75-
let element = ref.current;
76-
element.addEventListener('animationend', onAnimationEnd);
77-
return () => {
78-
element.removeEventListener('animationend', onAnimationEnd);
79-
};
80-
} else {
77+
if (!('getAnimations' in ref.current)) {
78+
// JSDOM
8179
onEnd();
80+
return;
8281
}
82+
83+
let animations = ref.current.getAnimations();
84+
if (animations.length === 0) {
85+
onEnd();
86+
return;
87+
}
88+
89+
let canceled = false;
90+
Promise.all(animations.map(a => a.finished)).then(() => {
91+
if (!canceled) {
92+
flushSync(() => {
93+
onEnd();
94+
});
95+
}
96+
}).catch(() => {});
97+
98+
return () => {
99+
canceled = true;
100+
};
83101
}
84102
}, [ref, isActive, onEnd]);
85103
}

packages/@react-spectrum/s2/src/ActionBar.tsx

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,13 @@ import {DOMRef, DOMRefValue, Key} from '@react-types/shared';
1919
import {FocusScope, useKeyboard} from 'react-aria';
2020
// @ts-ignore
2121
import intlMessages from '../intl/*.json';
22-
import {keyframes} from '../style/style-macro' with {type: 'macro'};
2322
import {style} from '../style' with {type: 'macro'};
2423
import {useControlledState} from '@react-stately/utils';
2524
import {useDOMRef} from '@react-spectrum/utils';
26-
import {useExitAnimation, useResizeObserver} from '@react-aria/utils';
25+
import {useEnterAnimation, useExitAnimation, useObjectRef, useResizeObserver} from '@react-aria/utils';
2726
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2827
import {useSpectrumContextProps} from './useSpectrumContextProps';
2928

30-
const slideIn = keyframes(`
31-
from { transform: translateY(100%); opacity: 0 }
32-
to { transform: translateY(0px); opacity: 1 }
33-
`);
34-
35-
const slideOut = keyframes(`
36-
from { transform: translateY(0px); opacity: 1 }
37-
to { transform: translateY(100%); opacity: 0 }
38-
`);
39-
4029
const actionBarStyles = style({
4130
borderRadius: 'lg',
4231
'--s2-container-bg': {
@@ -77,11 +66,16 @@ const actionBarStyles = style({
7766
},
7867
marginX: 'auto',
7968
maxWidth: 960,
80-
animation: {
81-
isInContainer: slideIn,
82-
isExiting: slideOut
69+
transition: 'default',
70+
transitionDuration: 200,
71+
translateY: {
72+
isEntering: 'full',
73+
isExiting: 'full'
8374
},
84-
animationDuration: 200
75+
opacity: {
76+
isEntering: 0,
77+
isExiting: 0
78+
}
8579
});
8680

8781
export interface ActionBarProps extends SlotProps {
@@ -158,12 +152,15 @@ const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps
158152
}
159153
}, [stringFormatter, scrollRef]);
160154

155+
let objectRef = useObjectRef(ref);
156+
let isEntering = useEnterAnimation(objectRef, !!scrollRef);
157+
161158
return (
162159
<FocusScope restoreFocus>
163160
<div
164-
ref={ref}
161+
ref={objectRef}
165162
{...keyboardProps}
166-
className={actionBarStyles({isEmphasized, isInContainer: !!scrollRef, isExiting})}
163+
className={actionBarStyles({isEmphasized, isInContainer: !!scrollRef, isEntering, isExiting})}
167164
style={{insetInlineEnd: `calc(var(--insetEnd) + ${scrollbarWidth}px)`}}>
168165
<div className={style({order: 1, marginStart: 'auto'})}>
169166
<ActionButtonGroup

packages/@react-spectrum/s2/src/Modal.tsx

Lines changed: 17 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {colorScheme} from './style-utils' with {type: 'macro'};
1414
import {ColorSchemeContext} from './Provider';
1515
import {DOMRef} from '@react-types/shared';
1616
import {forwardRef, MutableRefObject, useCallback, useContext} from 'react';
17-
import {keyframes} from '../style/style-macro' with {type: 'macro'};
1817
import {ModalOverlay, ModalOverlayProps, Modal as RACModal, useLocale} from 'react-aria-components';
1918
import {style} from '../style' with {type: 'macro'};
2019
import {useDOMRef} from '@react-spectrum/utils';
@@ -28,28 +27,6 @@ interface ModalProps extends ModalOverlayProps {
2827
size?: 'S' | 'M' | 'L' | 'fullscreen' | 'fullscreenTakeover'
2928
}
3029

31-
const fade = keyframes(`
32-
from {
33-
opacity: 0;
34-
}
35-
36-
to {
37-
opacity: 1;
38-
}
39-
`);
40-
41-
const fadeAndSlide = keyframes(`
42-
from {
43-
opacity: 0;
44-
transform: translateY(20px);
45-
}
46-
47-
to {
48-
opacity: 1;
49-
transform: translateY(0);
50-
}
51-
`);
52-
5330
const modalOverlayStyles = style({
5431
...colorScheme(),
5532
position: 'fixed',
@@ -59,17 +36,14 @@ const modalOverlayStyles = style({
5936
display: 'flex',
6037
alignItems: 'center',
6138
justifyContent: 'center',
62-
animation: {
63-
isEntering: fade,
64-
isExiting: fade
39+
opacity: {
40+
isEntering: 0,
41+
isExiting: 0
6542
},
66-
animationDuration: {
67-
isEntering: 250,
43+
transition: 'opacity',
44+
transitionDuration: {
45+
default: 250,
6846
isExiting: 130
69-
},
70-
animationDirection: {
71-
isEntering: 'normal',
72-
isExiting: 'reverse'
7347
}
7448
});
7549

@@ -141,23 +115,22 @@ export const Modal = forwardRef(function Modal(props: ModalProps, ref: DOMRef<HT
141115
value: 'layer-2'
142116
},
143117
backgroundColor: '--s2-container-bg',
144-
animation: {
145-
isEntering: fadeAndSlide,
146-
isExiting: fade
118+
opacity: {
119+
isEntering: 0,
120+
isExiting: 0
121+
},
122+
translateY: {
123+
isEntering: 20
147124
},
148-
animationDuration: {
149-
isEntering: 250,
125+
transition: '[opacity, translate]',
126+
transitionDuration: {
127+
default: 250,
150128
isExiting: 130
151129
},
152-
animationDelay: {
153-
isEntering: 160,
130+
transitionDelay: {
131+
default: 160,
154132
isExiting: 0
155133
},
156-
animationDirection: {
157-
isEntering: 'normal',
158-
isExiting: 'reverse'
159-
},
160-
animationFillMode: 'both',
161134
// Transparent outline for WHCM.
162135
outlineStyle: 'solid',
163136
outlineWidth: 1,

0 commit comments

Comments
 (0)