From ad1602f607babe6ea55189eef5825eedbb047047 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 6 Aug 2025 13:53:51 -0700 Subject: [PATCH] feat: Add support for origin-aware overlay animations --- .../overlays/src/calculatePosition.ts | 24 ++++++++++++++-- .../overlays/src/useOverlayPosition.ts | 19 +++++++++++-- .../@react-aria/overlays/src/usePopover.ts | 11 ++++++-- .../overlays/test/calculatePosition.test.ts | 12 ++++++-- .../react-aria-components/docs/Popover.mdx | 8 ++++-- .../react-aria-components/docs/Tooltip.mdx | 6 ++-- .../react-aria-components/src/Popover.tsx | 17 ++++++----- .../react-aria-components/src/Tooltip.tsx | 23 ++++++--------- .../stories/Popover.stories.tsx | 28 ++++++++++++++++--- .../stories/Tooltip.stories.tsx | 26 +++++++++++++---- .../react-aria-components/stories/styles.css | 26 +++++++++++++++++ 11 files changed, 153 insertions(+), 47 deletions(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 2e929bdbfb7..4b1c5955003 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -67,6 +67,7 @@ export interface PositionResult { position: Position, arrowOffsetLeft?: number, arrowOffsetTop?: number, + triggerOrigin: {x: number, y: number}, maxHeight: number, placement: PlacementAxis } @@ -419,7 +420,8 @@ export function calculatePositionInternal( // childOffset[crossAxis] + .5 * childOffset[crossSize] = absolute position with respect to the trigger's coordinate system that would place the arrow in the center of the trigger // position[crossAxis] - margins[AXIS[crossAxis]] = value use to transform the position to a value with respect to the overlay's coordinate system. A child element's (aka arrow) position absolute's "0" // is positioned after the margin of its parent (aka overlay) so we need to subtract it to get the proper coordinate transform - let preferredArrowPosition = childOffset[crossAxis] + .5 * childOffset[crossSize] - position[crossAxis]! - margins[AXIS[crossAxis]]; + let origin = childOffset[crossAxis] - position[crossAxis]! - margins[AXIS[crossAxis]]; + let preferredArrowPosition = origin + .5 * childOffset[crossSize]; // Min/Max position limits for the arrow with respect to the overlay const arrowMinPosition = arrowSize / 2 + arrowBoundaryOffset; @@ -436,12 +438,30 @@ export function calculatePositionInternal( const arrowPositionOverlappingChild = clamp(preferredArrowPosition, arrowOverlappingChildMinEdge, arrowOverlappingChildMaxEdge); arrowPosition[crossAxis] = clamp(arrowPositionOverlappingChild, arrowMinPosition, arrowMaxPosition); + // If there is an arrow, use that as the origin so that animations are smooth. + // Otherwise use the target edge. + ({placement, crossPlacement} = placementInfo); + if (arrowSize) { + origin = arrowPosition[crossAxis]; + } else if (crossPlacement === 'right') { + origin += childOffset[crossSize]; + } else if (crossPlacement === 'center') { + origin += childOffset[crossSize] / 2; + } + + let crossOrigin = placement === 'left' || placement === 'top' ? overlaySize[size] : 0; + let triggerOrigin = { + x: placement === 'top' || placement === 'bottom' ? origin : crossOrigin, + y: placement === 'left' || placement === 'right' ? origin : crossOrigin + }; + return { position, maxHeight: maxHeight, arrowOffsetLeft: arrowPosition.left, arrowOffsetTop: arrowPosition.top, - placement: placementInfo.placement + placement, + triggerOrigin }; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index d5bf2ee1e06..b2fc3572d76 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -37,6 +37,10 @@ export interface AriaPositionProps extends PositionProps { * The ref for the overlay element. */ overlayRef: RefObject, + /** + * The ref for the arrow element. + */ + arrowRef?: RefObject, /** * A ref for the scrollable region within the overlay. * @default overlayRef @@ -68,6 +72,8 @@ export interface PositionAria { arrowProps: DOMAttributes, /** Placement of the overlay with respect to the overlay trigger. */ placement: PlacementAxis | null, + /** The origin of the target in the overlay's coordinate system. Useful for animations. */ + triggerOrigin: {x: number, y: number} | null, /** Updates the position of the overlay. */ updatePosition(): void } @@ -86,9 +92,10 @@ let visualViewport = typeof document !== 'undefined' ? window.visualViewport : n export function useOverlayPosition(props: AriaPositionProps): PositionAria { let {direction} = useLocale(); let { - arrowSize = 0, + arrowSize, targetRef, overlayRef, + arrowRef, scrollRef = overlayRef, placement = 'bottom' as Placement, containerPadding = 12, @@ -109,6 +116,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { placement, overlayRef.current, targetRef.current, + arrowRef?.current, scrollRef.current, containerPadding, shouldFlip, @@ -141,6 +149,12 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { return; } + // Don't update while the overlay is animating. + // Things like scale animations can mess up positioning by affecting the overlay's computed size. + if (overlayRef.current.getAnimations?.().length > 0) { + return; + } + // Determine a scroll anchor based on the focused element. // This stores the offset of the anchor element from the scroll container // so it can be restored after repositioning. This way if the overlay height @@ -181,7 +195,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { offset, crossOffset, maxHeight, - arrowSize, + arrowSize: arrowSize ?? arrowRef?.current?.getBoundingClientRect().width ?? 0, arrowBoundaryOffset }); @@ -287,6 +301,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { } }, placement: position?.placement ?? null, + triggerOrigin: position?.triggerOrigin ?? null, arrowProps: { 'aria-hidden': 'true', role: 'presentation', diff --git a/packages/@react-aria/overlays/src/usePopover.ts b/packages/@react-aria/overlays/src/usePopover.ts index 281c3509b8e..ba66ae49704 100644 --- a/packages/@react-aria/overlays/src/usePopover.ts +++ b/packages/@react-aria/overlays/src/usePopover.ts @@ -29,6 +29,8 @@ export interface AriaPopoverProps extends Omit, + /** A ref for the popover arrow element. */ + arrowRef?: RefObject, /** * An optional ref for a group of popovers, e.g. submenus. * When provided, this element is used to detect outside interactions @@ -70,7 +72,9 @@ export interface PopoverAria { /** Props to apply to the underlay element, if any. */ underlayProps: DOMAttributes, /** Placement of the popover with respect to the trigger. */ - placement: PlacementAxis | null + placement: PlacementAxis | null, + /** The origin of the target in the overlay's coordinate system. Useful for animations. */ + triggerOrigin: {x: number, y: number} | null } /** @@ -102,7 +106,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): groupRef ?? popoverRef ); - let {overlayProps: positionProps, arrowProps, placement} = useOverlayPosition({ + let {overlayProps: positionProps, arrowProps, placement, triggerOrigin: origin} = useOverlayPosition({ ...otherProps, targetRef: triggerRef, overlayRef: popoverRef, @@ -128,6 +132,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): popoverProps: mergeProps(overlayProps, positionProps), arrowProps, underlayProps, - placement + placement, + triggerOrigin: origin }; } diff --git a/packages/@react-aria/overlays/test/calculatePosition.test.ts b/packages/@react-aria/overlays/test/calculatePosition.test.ts index 572a26f768e..f9020d46322 100644 --- a/packages/@react-aria/overlays/test/calculatePosition.test.ts +++ b/packages/@react-aria/overlays/test/calculatePosition.test.ts @@ -119,13 +119,19 @@ describe('calculatePosition', function () { pos.top = expected[1]; } + let calculatedPlacement = flip ? FLIPPED_DIRECTION[placementAxis] : placementAxis; + // Note that a crossAxis of 'bottom' indicates that the overlay grows towards the top since the bottom of the overlay aligns with the bottom of the trigger + let maxHeight = expected[4] - (placementAxis !== 'top' && placementCrossAxis !== 'bottom' ? providerOffset : 0); const expectedPosition = { position: pos, arrowOffsetLeft: expected[2], arrowOffsetTop: expected[3], - // Note that a crossAxis of 'bottom' indicates that the overlay grows towards the top since the bottom of the overlay aligns with the bottom of the trigger - maxHeight: expected[4] - (placementAxis !== 'top' && placementCrossAxis !== 'bottom' ? providerOffset : 0), - placement: flip ? FLIPPED_DIRECTION[placementAxis] : placementAxis + maxHeight, + placement: calculatedPlacement, + triggerOrigin: { + x: expected[2] ?? (calculatedPlacement === 'left' ? overlaySize.width : 0), + y: expected[3] ?? (calculatedPlacement === 'top' ? Math.min(overlaySize.height, maxHeight) : 0) + } }; const container = createElementWithDimensions('div', containerDimensions); diff --git a/packages/react-aria-components/docs/Popover.mdx b/packages/react-aria-components/docs/Popover.mdx index 50fc2db673c..f9de584beeb 100644 --- a/packages/react-aria-components/docs/Popover.mdx +++ b/packages/react-aria-components/docs/Popover.mdx @@ -438,15 +438,17 @@ The `className` and `style` props also accept functions which receive states for ``` -Popovers also support entry and exit animations via states exposed as data attributes and render props. `Popover` will automatically wait for any exit animations to complete before it is removed from the DOM. See the [animation guide](styling.html#animation) for more details. +Popovers also support entry and exit animations via states exposed as data attributes and render props. `Popover` will automatically wait for any exit animations to complete before it is removed from the DOM. The `--trigger-origin` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. See the [animation guide](styling.html#animation) for more details. ```css render=false .react-aria-Popover { - transition: opacity 300ms; + transition: opacity 300ms, scale 300ms; + transform-origin: var(--trigger-origin); &[data-entering], &[data-exiting] { opacity: 0; + scale: 0.85; } } ``` @@ -459,7 +461,7 @@ A `Popover` can be targeted with the `.react-aria-Popover` CSS selector, or by o -Within a DialogTrigger, the popover will have the `data-trigger="DialogTrigger"` attribute. In addition, the `--trigger-width` CSS custom property will be set on the popover, which you can use to make the popover match the width of the trigger button. +Within a DialogTrigger, the popover will have the `data-trigger="DialogTrigger"` attribute. In addition, the `--trigger-width` CSS custom property will be set on the popover, which you can use to make the popover match the width of the trigger button. The `--trigger-origin` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. ```css render=false .react-aria-Popover[data-trigger=DialogTrigger] { diff --git a/packages/react-aria-components/docs/Tooltip.mdx b/packages/react-aria-components/docs/Tooltip.mdx index 0d8784ca36a..925b0842e65 100644 --- a/packages/react-aria-components/docs/Tooltip.mdx +++ b/packages/react-aria-components/docs/Tooltip.mdx @@ -396,15 +396,17 @@ The `className` and `style` props also accept functions which receive states for ``` -Tooltips also support entry and exit animations via states exposed as data attributes and render props. `Tooltip` will automatically wait for any exit animations to complete before it is removed from the DOM. See the [animation guide](styling.html#animation) for more details. +Tooltips also support entry and exit animations via states exposed as data attributes and render props. `Tooltip` will automatically wait for any exit animations to complete before it is removed from the DOM. The `--trigger-origin` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. See the [animation guide](styling.html#animation) for more details. ```css render=false .react-aria-Tooltip { - transition: opacity 300ms; + transition: opacity 300ms, scale 300ms; + transform-origin: var(--trigger-origin); &[data-entering], &[data-exiting] { opacity: 0; + scale: 0.85; } } ``` diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 9e32e86b284..13ae55ab81b 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -147,20 +147,14 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts // Calculate the arrow size internally (and remove props.arrowSize from PopoverProps) // Referenced from: packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx let arrowRef = useRef(null); - let [arrowWidth, setArrowWidth] = useState(0); let containerRef = useRef(null); let groupCtx = useContext(PopoverGroupContext); let isSubPopover = groupCtx && props.trigger === 'SubmenuTrigger'; - useLayoutEffect(() => { - if (arrowRef.current && state.isOpen) { - setArrowWidth(arrowRef.current.getBoundingClientRect().width); - } - }, [state.isOpen, arrowRef]); - let {popoverProps, underlayProps, arrowProps, placement} = usePopover({ + let {popoverProps, underlayProps, arrowProps, placement, triggerOrigin} = usePopover({ ...props, offset: props.offset ?? 8, - arrowSize: arrowWidth, + arrowRef, // If this is a submenu/subdialog, use the root popover's container // to detect outside interaction and add aria-hidden. groupRef: isSubPopover ? groupCtx! : containerRef @@ -207,7 +201,12 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts return children; }, [renderProps.children, clearContexts]); - let style = {...popoverProps.style, ...renderProps.style}; + let style = { + ...popoverProps.style, + '--trigger-origin': triggerOrigin ? `${triggerOrigin.x}px ${triggerOrigin.y}px` : undefined, + ...renderProps.style + }; + let overlay = (
}) { let state = useContext(TooltipTriggerStateContext)!; - - // Calculate the arrow size internally - // Referenced from: packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx let arrowRef = useRef(null); - let [arrowWidth, setArrowWidth] = useState(0); - useLayoutEffect(() => { - if (arrowRef.current && state.isOpen) { - setArrowWidth(arrowRef.current.getBoundingClientRect().width); - } - }, [state.isOpen, arrowRef]); - let {overlayProps, arrowProps, placement} = useOverlayPosition({ + let {overlayProps, arrowProps, placement, triggerOrigin} = useOverlayPosition({ placement: props.placement || 'top', targetRef: props.triggerRef!, overlayRef: props.tooltipRef, + arrowRef, offset: props.offset, crossOffset: props.crossOffset, isOpen: state.isOpen, - arrowSize: arrowWidth, arrowBoundaryOffset: props.arrowBoundaryOffset, shouldFlip: props.shouldFlip, containerPadding: props.containerPadding, @@ -167,7 +158,11 @@ function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: Ref
diff --git a/packages/react-aria-components/stories/Popover.stories.tsx b/packages/react-aria-components/stories/Popover.stories.tsx index d75e7d7e954..5b3c859927d 100644 --- a/packages/react-aria-components/stories/Popover.stories.tsx +++ b/packages/react-aria-components/stories/Popover.stories.tsx @@ -13,20 +13,35 @@ import {Button, Dialog, DialogTrigger, Heading, OverlayArrow, Popover} from 'react-aria-components'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import React, {JSX, useEffect, useRef, useState} from 'react'; -import './styles.css'; +import styles from './styles.css'; export default { title: 'React Aria Components/Popover', - component: Popover + component: Popover, + args: { + placement: 'bottom start', + hideArrow: false + }, + argTypes: { + placement: { + control: 'select', + options: ['bottom', 'bottom left', 'bottom right', 'bottom start', 'bottom end', + 'top', 'top left', 'top right', 'top start', 'top end', + 'left', 'left top', 'left bottom', 'start', 'start top', 'start bottom', + 'right', 'right top', 'right bottom', 'end', 'end top', 'end bottom' + ] + } + } } as Meta; export type PopoverStory = StoryFn; -export const PopoverExample: PopoverStory = () => ( +export const PopoverExample: PopoverStory = (args) => ( ( padding: 30, zIndex: 5 }}> + {!(args as any).hideArrow && + + + + } {({close}) => (
diff --git a/packages/react-aria-components/stories/Tooltip.stories.tsx b/packages/react-aria-components/stories/Tooltip.stories.tsx index cb14766e25a..6b4c0d8c026 100644 --- a/packages/react-aria-components/stories/Tooltip.stories.tsx +++ b/packages/react-aria-components/stories/Tooltip.stories.tsx @@ -13,20 +13,36 @@ import {Button, OverlayArrow, Tooltip, TooltipTrigger} from 'react-aria-components'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import React, {JSX} from 'react'; -import './styles.css'; +import styles from './styles.css'; export default { title: 'React Aria Components/Tooltip', - component: Tooltip + component: Tooltip, + args: { + placement: 'top', + hideArrow: false + }, + argTypes: { + placement: { + control: 'select', + options: ['bottom', 'bottom left', 'bottom right', 'bottom start', 'bottom end', + 'top', 'top left', 'top right', 'top start', 'top end', + 'left', 'left top', 'left bottom', 'start', 'start top', 'start bottom', + 'right', 'right top', 'right bottom', 'end', 'end top', 'end bottom' + ] + } + } } as Meta; export type TooltipStory = StoryFn; export type TooltipStoryObj = StoryObj; -export const TooltipExample: TooltipStory = () => ( +export const TooltipExample: TooltipStory = (args) => ( ( padding: 5, borderRadius: 4 }}> - + {!(args as any).hideArrow && - + } I am a tooltip diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 451d50eb9ba..c2c4cccfc11 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -398,3 +398,29 @@ align-items: center } } + +.popover, +.tooltip { + transition: scale 300ms, opacity 300ms; + transform-origin: var(--trigger-origin); + + &[data-entering], + &[data-exiting] { + opacity: 0; + scale: 0.85; + } + + :global(.react-aria-OverlayArrow) { + &[data-placement=bottom] svg { + transform: rotate(180deg); + } + + &[data-placement=left] svg { + transform: rotate(-90deg); + } + + &[data-placement=right] svg { + transform: rotate(90deg); + } + } +}