Skip to content

Commit

Permalink
feat: add animation end callbacks
Browse files Browse the repository at this point in the history
This PR adds `onEnterComplete` and `onExitComplete` callbacks to components in
`react-aria-components` which have enter and exit animations.

Closes #7630.
  • Loading branch information
cprussin committed Jan 19, 2025
1 parent cdba748 commit 951c9a2
Show file tree
Hide file tree
Showing 7 changed files with 58 additions and 47 deletions.
16 changes: 10 additions & 6 deletions packages/@react-aria/utils/src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import {flushSync} from 'react-dom';
import {RefObject, useCallback, useState} from 'react';
import {useLayoutEffect} from './useLayoutEffect';

export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: boolean = true) {
export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: boolean = true, onEnd?: (() => void) | undefined) {
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.
Expand All @@ -34,11 +34,14 @@ export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: b
}
}, [ref, isAnimationReady]);

useAnimation(ref, isAnimationReady, useCallback(() => setEntering(false), []));
useAnimation(ref, isAnimationReady, useCallback(() => {
setEntering(false);
onEnd?.();
}, []));
return isAnimationReady;
}

export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boolean) {
export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boolean, onEnd?: (() => void) | undefined) {
let [exitState, setExitState] = useState<'closed' | 'open' | 'exiting'>(isOpen ? 'open' : 'closed');

switch (exitState) {
Expand All @@ -65,6 +68,7 @@ export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boo
useCallback(() => {
// Set the state to closed, which will cause the element to be unmounted.
setExitState(state => state === 'exiting' ? 'closed' : state);
onEnd?.();
}, [])
);

Expand All @@ -79,7 +83,7 @@ function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onE
onEnd();
return;
}

let animations = ref.current.getAnimations();
if (animations.length === 0) {
onEnd();
Expand All @@ -94,7 +98,7 @@ function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onE
});
}
}).catch(() => {});

return () => {
canceled = true;
};
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/src/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps
if ((selectedItemCount === 'all' || selectedItemCount > 0) && selectedItemCount !== lastCount) {
setLastCount(selectedItemCount);
}

// Measure the width of the collection's scrollbar and offset the action bar by that amount.
let scrollRef = props.scrollRef;
let [scrollbarWidth, setScrollbarWidth] = useState(0);
Expand Down
30 changes: 30 additions & 0 deletions packages/@react-types/shared/src/animation.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

export interface AnimationProps {
/**
* Whether the popover is currently performing an entry animation.
*/
isEntering?: boolean,
/**
* Whether the popover is currently performing an exit animation.
*/
isExiting?: boolean,
/**
* A callback that will be called when the enter animation is completed.
*/
onEnterComplete?: () => void,
/**
* A callback that will be called when the exit animation is completed.
*/
onExitComplete?: () => void
}
1 change: 1 addition & 0 deletions packages/@react-types/shared/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

export * from './animation';
export * from './dom';
export * from './inputs';
export * from './selection';
Expand Down
22 changes: 7 additions & 15 deletions packages/react-aria-components/src/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,15 @@
* governing permissions and limitations under the License.
*/

import {AnimationProps, DOMAttributes, forwardRefType, RefObject} from '@react-types/shared';
import {AriaModalOverlayProps, DismissButton, Overlay, useIsSSR, useModalOverlay} from 'react-aria';
import {ContextValue, Provider, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils';
import {DOMAttributes, forwardRefType, RefObject} from '@react-types/shared';
import {filterDOMProps, mergeProps, mergeRefs, useEnterAnimation, useExitAnimation, useObjectRef, useViewportSize} from '@react-aria/utils';
import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately';
import {OverlayTriggerStateContext} from './Dialog';
import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo, useRef} from 'react';

export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTriggerProps, RenderProps<ModalRenderProps>, SlotProps {
/**
* Whether the modal is currently performing an entry animation.
*/
isEntering?: boolean,
/**
* Whether the modal is currently performing an exit animation.
*/
isExiting?: boolean,
export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTriggerProps, RenderProps<ModalRenderProps>, SlotProps, AnimationProps {
/**
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
* @default document.body
Expand Down Expand Up @@ -118,8 +110,8 @@ function ModalOverlayWithForwardRef(props: ModalOverlayProps, ref: ForwardedRef<

let objectRef = useObjectRef(ref);
let modalRef = useRef<HTMLDivElement>(null);
let isOverlayExiting = useExitAnimation(objectRef, state.isOpen);
let isModalExiting = useExitAnimation(modalRef, state.isOpen);
let isOverlayExiting = useExitAnimation(objectRef, state.isOpen, props.onExitComplete);
let isModalExiting = useExitAnimation(modalRef, state.isOpen, props.onEnterComplete);
let isExiting = isOverlayExiting || isModalExiting || props.isExiting || false;
let isSSR = useIsSSR();

Expand Down Expand Up @@ -147,7 +139,7 @@ function ModalOverlayInner({UNSTABLE_portalContainer, ...props}: ModalOverlayInn
let {state} = props;
let {modalProps, underlayProps} = useModalOverlay(props, state, modalRef);

let entering = useEnterAnimation(props.overlayRef) || props.isEntering || false;
let entering = useEnterAnimation(props.overlayRef, undefined, props.onEnterComplete) || props.isEntering || false;
let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-ModalOverlay',
Expand Down Expand Up @@ -185,7 +177,7 @@ function ModalOverlayInner({UNSTABLE_portalContainer, ...props}: ModalOverlayInn
);
}

interface ModalContentProps extends RenderProps<ModalRenderProps> {
interface ModalContentProps extends RenderProps<ModalRenderProps>, AnimationProps {
modalRef: ForwardedRef<HTMLDivElement>
}

Expand All @@ -195,7 +187,7 @@ function ModalContent(props: ModalContentProps) {
let mergedRefs = useMemo(() => mergeRefs(props.modalRef, modalRef), [props.modalRef, modalRef]);

let ref = useObjectRef(mergedRefs);
let entering = useEnterAnimation(ref);
let entering = useEnterAnimation(ref, undefined, props.onEnterComplete);
let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-Modal',
Expand Down
18 changes: 5 additions & 13 deletions packages/react-aria-components/src/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@
* governing permissions and limitations under the License.
*/

import {AnimationProps, forwardRefType, RefObject} from '@react-types/shared';
import {AriaPopoverProps, DismissButton, Overlay, PlacementAxis, PositionProps, usePopover} from 'react-aria';
import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils';
import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils';
import {forwardRefType, RefObject} from '@react-types/shared';
import {OverlayArrowContext} from './OverlayArrow';
import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately';
import {OverlayTriggerStateContext} from './Dialog';
import React, {createContext, ForwardedRef, forwardRef, useContext, useRef, useState} from 'react';
import {useIsHidden} from '@react-aria/collections';

export interface PopoverProps extends Omit<PositionProps, 'isOpen'>, Omit<AriaPopoverProps, 'popoverRef' | 'triggerRef' | 'offset' | 'arrowSize'>, OverlayTriggerProps, RenderProps<PopoverRenderProps>, SlotProps {
export interface PopoverProps extends Omit<PositionProps, 'isOpen'>, Omit<AriaPopoverProps, 'popoverRef' | 'triggerRef' | 'offset' | 'arrowSize'>, OverlayTriggerProps, RenderProps<PopoverRenderProps>, SlotProps, AnimationProps {
/**
* The name of the component that triggered the popover. This is reflected on the element
* as the `data-trigger` attribute, and can be used to provide specific
Expand All @@ -34,14 +34,6 @@ export interface PopoverProps extends Omit<PositionProps, 'isOpen'>, Omit<AriaPo
* this is set automatically. It is only required when used standalone.
*/
triggerRef?: RefObject<Element | null>,
/**
* Whether the popover is currently performing an entry animation.
*/
isEntering?: boolean,
/**
* Whether the popover is currently performing an exit animation.
*/
isExiting?: boolean,
/**
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
* @default document.body
Expand Down Expand Up @@ -88,7 +80,7 @@ export const Popover = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pop
let contextState = useContext(OverlayTriggerStateContext);
let localState = useOverlayTriggerState(props);
let state = props.isOpen != null || props.defaultOpen != null || !contextState ? localState : contextState;
let isExiting = useExitAnimation(ref, state.isOpen) || props.isExiting || false;
let isExiting = useExitAnimation(ref, state.isOpen, props.onExitComplete) || props.isExiting || false;
let isHidden = useIsHidden();

// If we are in a hidden tree, we still need to preserve our children.
Expand Down Expand Up @@ -121,7 +113,7 @@ export const Popover = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pop
);
});

interface PopoverInnerProps extends AriaPopoverProps, RenderProps<PopoverRenderProps>, SlotProps {
interface PopoverInnerProps extends AriaPopoverProps, RenderProps<PopoverRenderProps>, SlotProps, AnimationProps {
state: OverlayTriggerState,
isEntering?: boolean,
isExiting: boolean,
Expand All @@ -147,7 +139,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: Po
}, state);

let ref = props.popoverRef as RefObject<HTMLDivElement | null>;
let isEntering = useEnterAnimation(ref, !!placement) || props.isEntering || false;
let isEntering = useEnterAnimation(ref, !!placement, props.onEnterComplete) || props.isEntering || false;
let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-Popover',
Expand Down
16 changes: 4 additions & 12 deletions packages/react-aria-components/src/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {AriaLabelingProps, FocusableElement, forwardRefType, RefObject} from '@react-types/shared';
import {AnimationProps, AriaLabelingProps, FocusableElement, forwardRefType, RefObject} from '@react-types/shared';
import {AriaPositionProps, mergeProps, OverlayContainer, Placement, PlacementAxis, PositionProps, useOverlayPosition, useTooltip, useTooltipTrigger} from 'react-aria';
import {ContextValue, Provider, RenderProps, useContextProps, useRenderProps} from './utils';
import {FocusableProvider} from '@react-aria/focus';
Expand All @@ -23,21 +23,13 @@ export interface TooltipTriggerComponentProps extends TooltipTriggerProps {
children: ReactNode
}

export interface TooltipProps extends PositionProps, Pick<AriaPositionProps, 'arrowBoundaryOffset'>, OverlayTriggerProps, AriaLabelingProps, RenderProps<TooltipRenderProps> {
export interface TooltipProps extends PositionProps, Pick<AriaPositionProps, 'arrowBoundaryOffset'>, OverlayTriggerProps, AriaLabelingProps, RenderProps<TooltipRenderProps>, AnimationProps {
/**
* The ref for the element which the tooltip positions itself with respect to.
*
* When used within a TooltipTrigger this is set automatically. It is only required when used standalone.
*/
triggerRef?: RefObject<Element | null>,
/**
* Whether the tooltip is currently performing an entry animation.
*/
isEntering?: boolean,
/**
* Whether the tooltip is currently performing an exit animation.
*/
isExiting?: boolean,
/**
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
* @default document.body
Expand Down Expand Up @@ -106,7 +98,7 @@ export const Tooltip = /*#__PURE__*/ (forwardRef as forwardRefType)(function Too
let contextState = useContext(TooltipTriggerStateContext);
let localState = useTooltipTriggerState(props);
let state = props.isOpen != null || props.defaultOpen != null || !contextState ? localState : contextState;
let isExiting = useExitAnimation(ref, state.isOpen) || props.isExiting || false;
let isExiting = useExitAnimation(ref, state.isOpen, props.onExitComplete) || props.isExiting || false;
if (!state.isOpen && !isExiting) {
return null;
}
Expand Down Expand Up @@ -144,7 +136,7 @@ function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: Ref
onClose: () => state.close(true)
});

let isEntering = useEnterAnimation(props.tooltipRef, !!placement) || props.isEntering || false;
let isEntering = useEnterAnimation(props.tooltipRef, !!placement, props.onEnterComplete) || props.isEntering || false;
let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-Tooltip',
Expand Down

0 comments on commit 951c9a2

Please sign in to comment.