Skip to content

Commit faae654

Browse files
authored
Add isEntering and isExiting props to control animation from outside (#5358)
1 parent a2678d3 commit faae654

File tree

9 files changed

+197
-54
lines changed

9 files changed

+197
-54
lines changed

packages/@react-aria/overlays/src/useOverlay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject<Element>): Ov
120120
};
121121

122122
// Handle clicking outside the overlay to close it
123-
useInteractOutside({ref, onInteractOutside: isDismissable ? onInteractOutside : null, onInteractOutsideStart});
123+
useInteractOutside({ref, onInteractOutside: isDismissable && isOpen ? onInteractOutside : null, onInteractOutsideStart});
124124

125125
let {focusWithinProps} = useFocusWithin({
126126
isDisabled: !shouldCloseOnBlur,

packages/react-aria-components/docs/styling.mdx

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -288,46 +288,61 @@ This enables using props like [animate](https://www.framer.com/motion/animation/
288288
```tsx
289289
<MotionModal
290290
initial={{opacity: 0}}
291-
animate={{opacity: 1}}
292-
exit={{opacity: 0}}>
291+
animate={{opacity: 1}}>
293292
{/* ... */}
294293
</MotionModal>
295294
```
296295

297-
Entry and exit animations can also be done with the [AnimatePresence](https://www.framer.com/motion/animate-presence/) component. Maintain your own `isOpen` state, and use this to control whether the child of `AnimatePresence` is rendered. Then, force the React Aria overlay component to always mount by setting the `isOpen` prop to true. This way, it won't unmount until the animation is complete.
296+
Overlay exit animations can be implemented using the `isExiting` prop, which keeps the element in the DOM until an animation is complete. Framer Motion's [variants](https://www.framer.com/motion/animation/#variants) are a good way to setup named animation states.
298297

299298
```tsx
300-
import {AnimatePresence} from 'framer-motion';
299+
type AnimationState = 'unmounted' | 'hidden' | 'visible';
301300

302301
function Example() {
303-
let [isOpen, setOpen] = React.useState(false);
302+
/*- begin highlight -*/
303+
// Track animation state.
304+
let [animation, setAnimation] = React.useState<AnimationState>('unmounted');
305+
/*- end highlight -*/
304306

305307
return (
306-
<AnimatePresence>
307-
{isOpen &&
308-
<MotionModalOverlay
309-
// Force mount so that AnimatePresence works.
310-
/*- begin highlight -*/
311-
isOpen
312-
/*- end highlight -*/
313-
onOpenChange={setOpen}
314-
initial={{opacity: 0}}
315-
animate={{opacity: 1}}
316-
exit={{opacity: 0}}>
317-
<MotionModal
318-
initial={{opacity: 0, y: 32}}
319-
animate={{opacity: 1, y: 0}}
320-
exit={{opacity: 0, y: 32}}>
321-
{/* ... */}
322-
</MotionModal>
323-
</MotionModalOverlay>
324-
}
325-
</AnimatePresence>
308+
<DialogTrigger
309+
/*- begin highlight -*/
310+
// Start animation when open state changes.
311+
onOpenChange={isOpen => setAnimation(isOpen ? 'visible' : 'hidden')}
312+
/*- end highlight -*/
313+
>
314+
<Button>Open dialog</Button>
315+
<MotionModalOverlay
316+
/*- begin highlight -*/
317+
// Prevent modal from unmounting during animation.
318+
isExiting={animation === 'hidden'}
319+
// Reset animation state once it is complete.
320+
onAnimationComplete={animation => {
321+
setAnimation(a => animation === 'hidden' && a === 'hidden' ? 'unmounted' : a)
322+
}}
323+
/*- end highlight -*/
324+
variants={{
325+
hidden: {opacity: 0},
326+
visible: {opacity: 1}
327+
}}
328+
initial="hidden"
329+
animate={animation}>
330+
<MotionModal
331+
variants={{
332+
hidden: {opacity: 0, y: 32},
333+
visible: {opacity: 1, y: 0}
334+
}}>
335+
{/* ... */}
336+
</MotionModal>
337+
</MotionModalOverlay>
338+
</DialogTrigger>
326339
);
327340
}
328341
```
329342

330-
`AnimatePresence` also allows you to animate when items are added or removed in collection components. Use `array.map` to create children, and make sure each child has a unique `key` in addition to an `id` to ensure Framer Motion can track it.
343+
**Note**: Framer Motion's `AnimatePresence` component may not work with React Aria overlays in all cases, so the example shown above is the recommended approach for exit animations.
344+
345+
The [AnimatePresence](https://www.framer.com/motion/animate-presence/) component allows you to animate when items are added or removed in collection components. Use `array.map` to create children, and make sure each child has a unique `key` in addition to an `id` to ensure Framer Motion can track it.
331346

332347
```tsx
333348
import {GridList, GridListItem} from 'react-aria-components';

packages/react-aria-components/src/Modal.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from '
1818
import {OverlayTriggerStateContext} from './Dialog';
1919
import React, {createContext, ForwardedRef, forwardRef, RefObject, useContext, useMemo, useRef} from 'react';
2020

21-
export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTriggerProps, RenderProps<ModalRenderProps>, SlotProps {}
21+
export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTriggerProps, RenderProps<ModalRenderProps>, SlotProps {
22+
/**
23+
* Whether the modal is currently performing an entry animation.
24+
*/
25+
isEntering?: boolean,
26+
/**
27+
* Whether the modal is currently performing an exit animation.
28+
*/
29+
isExiting?: boolean
30+
}
2231

2332
interface InternalModalContextValue {
2433
modalProps: DOMAttributes,
@@ -61,6 +70,8 @@ function Modal(props: ModalOverlayProps, ref: ForwardedRef<HTMLDivElement>) {
6170
defaultOpen,
6271
onOpenChange,
6372
children,
73+
isEntering,
74+
isExiting,
6475
...otherProps
6576
} = props;
6677

@@ -70,7 +81,9 @@ function Modal(props: ModalOverlayProps, ref: ForwardedRef<HTMLDivElement>) {
7081
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
7182
isOpen={isOpen}
7283
defaultOpen={defaultOpen}
73-
onOpenChange={onOpenChange}>
84+
onOpenChange={onOpenChange}
85+
isEntering={isEntering}
86+
isExiting={isExiting}>
7487
<ModalContent {...otherProps} modalRef={ref}>
7588
{children}
7689
</ModalContent>
@@ -101,7 +114,7 @@ function ModalOverlayWithForwardRef(props: ModalOverlayProps, ref: ForwardedRef<
101114
let modalRef = useRef<HTMLDivElement>(null);
102115
let isOverlayExiting = useExitAnimation(objectRef, state.isOpen);
103116
let isModalExiting = useExitAnimation(modalRef, state.isOpen);
104-
let isExiting = isOverlayExiting || isModalExiting;
117+
let isExiting = isOverlayExiting || isModalExiting || props.isExiting || false;
105118
let isSSR = useIsSSR();
106119

107120
if ((!state.isOpen && !isExiting) || isSSR) {
@@ -128,7 +141,7 @@ function ModalOverlayInner(props: ModalOverlayInnerProps) {
128141
let {state} = props;
129142
let {modalProps, underlayProps} = useModalOverlay(props, state, modalRef);
130143

131-
let entering = useEnterAnimation(props.overlayRef);
144+
let entering = useEnterAnimation(props.overlayRef) || props.isEntering || false;
132145
let renderProps = useRenderProps({
133146
...props,
134147
defaultClassName: 'react-aria-ModalOverlay',

packages/react-aria-components/src/Popover.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ export interface PopoverProps extends Omit<PositionProps, 'isOpen'>, Omit<AriaPo
2525
* When used within a trigger component such as DialogTrigger, MenuTrigger, Select, etc.,
2626
* this is set automatically. It is only required when used standalone.
2727
*/
28-
triggerRef?: RefObject<Element>
28+
triggerRef?: RefObject<Element>,
29+
/**
30+
* Whether the popover is currently performing an entry animation.
31+
*/
32+
isEntering?: boolean,
33+
/**
34+
* Whether the popover is currently performing an exit animation.
35+
*/
36+
isExiting?: boolean
2937
}
3038

3139
export interface PopoverRenderProps {
@@ -53,7 +61,7 @@ function Popover(props: PopoverProps, ref: ForwardedRef<HTMLElement>) {
5361
let contextState = useContext(OverlayTriggerStateContext);
5462
let localState = useOverlayTriggerState(props);
5563
let state = props.isOpen != null || props.defaultOpen != null || !contextState ? localState : contextState;
56-
let isExiting = useExitAnimation(ref, state.isOpen);
64+
let isExiting = useExitAnimation(ref, state.isOpen) || props.isExiting || false;
5765
let isHidden = useContext(HiddenContext);
5866

5967
// If we are in a hidden tree, we still need to preserve our children.
@@ -92,6 +100,7 @@ export {_Popover as Popover};
92100

93101
interface PopoverInnerProps extends AriaPopoverProps, RenderProps<PopoverRenderProps>, SlotProps {
94102
state: OverlayTriggerState,
103+
isEntering?: boolean,
95104
isExiting: boolean
96105
}
97106

@@ -102,7 +111,7 @@ function PopoverInner({state, isExiting, ...props}: PopoverInnerProps) {
102111
}, state);
103112

104113
let ref = props.popoverRef as RefObject<HTMLDivElement>;
105-
let isEntering = useEnterAnimation(ref, !!placement);
114+
let isEntering = useEnterAnimation(ref, !!placement) || props.isEntering || false;
106115
let renderProps = useRenderProps({
107116
...props,
108117
defaultClassName: 'react-aria-Popover',
@@ -117,7 +126,7 @@ function PopoverInner({state, isExiting, ...props}: PopoverInnerProps) {
117126

118127
return (
119128
<Overlay isExiting={isExiting}>
120-
{!props.isNonModal && <div {...underlayProps} style={{position: 'fixed', inset: 0}} />}
129+
{!props.isNonModal && state.isOpen && <div {...underlayProps} style={{position: 'fixed', inset: 0}} />}
121130
<div
122131
{...mergeProps(filterDOMProps(props as any), popoverProps)}
123132
{...renderProps}

packages/react-aria-components/src/Tooltip.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,15 @@ export interface TooltipProps extends PositionProps, OverlayTriggerProps, AriaLa
2828
*
2929
* When used within a TooltipTrigger this is set automatically. It is only required when used standalone.
3030
*/
31-
triggerRef?: RefObject<Element>
31+
triggerRef?: RefObject<Element>,
32+
/**
33+
* Whether the tooltip is currently performing an entry animation.
34+
*/
35+
isEntering?: boolean,
36+
/**
37+
* Whether the tooltip is currently performing an exit animation.
38+
*/
39+
isExiting?: boolean
3240
}
3341

3442
export interface TooltipRenderProps {
@@ -84,7 +92,7 @@ function Tooltip(props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
8492
let contextState = useContext(TooltipTriggerStateContext);
8593
let localState = useTooltipTriggerState(props);
8694
let state = props.isOpen != null || props.defaultOpen != null || !contextState ? localState : contextState;
87-
let isExiting = useExitAnimation(ref, state.isOpen);
95+
let isExiting = useExitAnimation(ref, state.isOpen) || props.isExiting || false;
8896
if (!state.isOpen && !isExiting) {
8997
return null;
9098
}
@@ -114,7 +122,7 @@ function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: Ref
114122
isOpen: state.isOpen
115123
});
116124

117-
let isEntering = useEnterAnimation(props.tooltipRef, !!placement);
125+
let isEntering = useEnterAnimation(props.tooltipRef, !!placement) || props.isEntering || false;
118126
let renderProps = useRenderProps({
119127
...props,
120128
defaultClassName: 'react-aria-Tooltip',

packages/react-aria-components/src/utils.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,19 @@ export function useContextProps<T, U extends SlotProps, E extends Element>(props
175175
let mergedRef = useObjectRef(useMemo(() => mergeRefs(ref, contextRef), [ref, contextRef]));
176176
let mergedProps = mergeProps(contextProps, props) as unknown as T;
177177

178+
// mergeProps does not merge `style`. Adding this there might be a breaking change.
179+
if (
180+
'style' in contextProps &&
181+
contextProps.style &&
182+
typeof contextProps.style === 'object' &&
183+
'style' in props &&
184+
props.style &&
185+
typeof props.style === 'object'
186+
) {
187+
// @ts-ignore
188+
mergedProps.style = {...contextProps.style, ...props.style};
189+
}
190+
178191
// A parent component might need the props from a child, so call slot callback if needed.
179192
useEffect(() => {
180193
if (callback) {
@@ -257,7 +270,7 @@ function useAnimation(ref: RefObject<HTMLElement>, isActive: boolean, onEnd: ()
257270
if (isActive && ref.current) {
258271
// Make sure there's actually an animation, and it wasn't there before we triggered the update.
259272
let computedStyle = window.getComputedStyle(ref.current);
260-
if (computedStyle.animationName !== 'none' && computedStyle.animation !== prevAnimation.current) {
273+
if (computedStyle.animationName && computedStyle.animationName !== 'none' && computedStyle.animation !== prevAnimation.current) {
261274
let onAnimationEnd = (e: AnimationEvent) => {
262275
if (e.target === ref.current) {
263276
element.removeEventListener('animationend', onAnimationEnd);

packages/react-aria-components/test/Dialog.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,4 +246,37 @@ describe('Dialog', () => {
246246
expect(onOpenChange).toHaveBeenCalledTimes(1);
247247
expect(onOpenChange).toHaveBeenCalledWith(false);
248248
});
249+
250+
it('supports isEntering and isExiting props', async () => {
251+
function TestModal(props) {
252+
return (
253+
<DialogTrigger>
254+
<Button />
255+
<Modal {...props}>
256+
<Dialog aria-label="Modal">A modal</Dialog>
257+
</Modal>
258+
</DialogTrigger>
259+
);
260+
}
261+
262+
let {getByRole, rerender} = render(<TestModal isEntering />);
263+
264+
let button = getByRole('button');
265+
await user.click(button);
266+
267+
let modal = getByRole('dialog').closest('.react-aria-ModalOverlay');
268+
expect(modal).toHaveAttribute('data-entering');
269+
270+
rerender(<TestModal />);
271+
expect(modal).not.toHaveAttribute('data-entering');
272+
273+
rerender(<TestModal isExiting />);
274+
await user.click(button);
275+
276+
expect(modal).toBeInTheDocument();
277+
expect(modal).toHaveAttribute('data-exiting');
278+
279+
rerender(<TestModal />);
280+
expect(modal).not.toBeInTheDocument();
281+
});
249282
});

packages/react-aria-components/test/Popover.test.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import {Button, Dialog, DialogTrigger, OverlayArrow, Popover} from '../';
1515
import React from 'react';
1616
import userEvent from '@testing-library/user-event';
1717

18-
let TestPopover = () => (
18+
let TestPopover = (props) => (
1919
<DialogTrigger>
2020
<Button />
21-
<Popover>
21+
<Popover {...props}>
2222
<OverlayArrow>
2323
<svg width={12} height={12}>
2424
<path d="M0 0,L6 6,L12 0" />
@@ -128,4 +128,26 @@ describe('Popover', () => {
128128
expect(onOpenChange).toHaveBeenCalledTimes(1);
129129
expect(onOpenChange).toHaveBeenCalledWith(false);
130130
});
131+
132+
it('supports isEntering and isExiting props', async () => {
133+
let {getByRole, rerender} = render(<TestPopover isEntering />);
134+
135+
let button = getByRole('button');
136+
await user.click(button);
137+
138+
let popover = getByRole('dialog').closest('.react-aria-Popover');
139+
expect(popover).toHaveAttribute('data-entering');
140+
141+
rerender(<TestPopover />);
142+
expect(popover).not.toHaveAttribute('data-entering');
143+
144+
rerender(<TestPopover isExiting />);
145+
await user.click(button);
146+
147+
expect(popover).toBeInTheDocument();
148+
expect(popover).toHaveAttribute('data-exiting');
149+
150+
rerender(<TestPopover />);
151+
expect(popover).not.toBeInTheDocument();
152+
});
131153
});

0 commit comments

Comments
 (0)