Skip to content

feat: Add support for origin-aware overlay animations #8681

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions packages/@react-aria/overlays/src/calculatePosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface PositionResult {
position: Position,
arrowOffsetLeft?: number,
arrowOffsetTop?: number,
triggerOrigin: {x: number, y: number},
maxHeight: number,
placement: PlacementAxis
}
Expand Down Expand Up @@ -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;
Expand All @@ -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
};
}

Expand Down
19 changes: 17 additions & 2 deletions packages/@react-aria/overlays/src/useOverlayPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export interface AriaPositionProps extends PositionProps {
* The ref for the overlay element.
*/
overlayRef: RefObject<Element | null>,
/**
* The ref for the arrow element.
*/
arrowRef?: RefObject<Element | null>,
/**
* A ref for the scrollable region within the overlay.
* @default overlayRef
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand All @@ -109,6 +116,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
placement,
overlayRef.current,
targetRef.current,
arrowRef?.current,
scrollRef.current,
containerPadding,
shouldFlip,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -181,7 +195,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
offset,
crossOffset,
maxHeight,
arrowSize,
arrowSize: arrowSize ?? arrowRef?.current?.getBoundingClientRect().width ?? 0,
arrowBoundaryOffset
});

Expand Down Expand Up @@ -287,6 +301,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
}
},
placement: position?.placement ?? null,
triggerOrigin: position?.triggerOrigin ?? null,
arrowProps: {
'aria-hidden': 'true',
role: 'presentation',
Expand Down
11 changes: 8 additions & 3 deletions packages/@react-aria/overlays/src/usePopover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface AriaPopoverProps extends Omit<AriaPositionProps, 'isOpen' | 'on
* The ref for the popover element.
*/
popoverRef: RefObject<Element | null>,
/** A ref for the popover arrow element. */
arrowRef?: RefObject<Element | null>,
/**
* An optional ref for a group of popovers, e.g. submenus.
* When provided, this element is used to detect outside interactions
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -128,6 +132,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
popoverProps: mergeProps(overlayProps, positionProps),
arrowProps,
underlayProps,
placement
placement,
triggerOrigin: origin
};
}
12 changes: 9 additions & 3 deletions packages/@react-aria/overlays/test/calculatePosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions packages/react-aria-components/docs/Popover.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -438,15 +438,17 @@ The `className` and `style` props also accept functions which receive states for
</OverlayArrow>
```

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;
}
}
```
Expand All @@ -459,7 +461,7 @@ A `Popover` can be targeted with the `.react-aria-Popover` CSS selector, or by o

<StateTable properties={docs.exports.PopoverRenderProps.properties} />

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] {
Expand Down
6 changes: 4 additions & 2 deletions packages/react-aria-components/docs/Tooltip.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -396,15 +396,17 @@ The `className` and `style` props also accept functions which receive states for
</OverlayArrow>
```

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;
}
}
```
Expand Down
17 changes: 8 additions & 9 deletions packages/react-aria-components/src/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
let [arrowWidth, setArrowWidth] = useState(0);
let containerRef = useRef<HTMLDivElement | null>(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
Expand Down Expand Up @@ -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 = (
<div
{...mergeProps(filterDOMProps(props, {global: true}), popoverProps)}
Expand Down
23 changes: 9 additions & 14 deletions packages/react-aria-components/src/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
import {AriaLabelingProps, FocusableElement, forwardRefType, GlobalDOMAttributes, 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 {filterDOMProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils';
import {filterDOMProps, useEnterAnimation, useExitAnimation} from '@react-aria/utils';
import {FocusableProvider} from '@react-aria/focus';
import {OverlayArrowContext} from './OverlayArrow';
import {OverlayTriggerProps, TooltipTriggerProps, TooltipTriggerState, useTooltipTriggerState} from 'react-stately';
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useRef, useState} from 'react';
import React, {createContext, CSSProperties, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useRef} from 'react';

export interface TooltipTriggerComponentProps extends TooltipTriggerProps {
children: ReactNode
Expand Down Expand Up @@ -121,25 +121,16 @@ export const Tooltip = /*#__PURE__*/ (forwardRef as forwardRefType)(function Too

function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: RefObject<HTMLDivElement | null>}) {
let state = useContext(TooltipTriggerStateContext)!;

// Calculate the arrow size internally
// Referenced from: packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx
let arrowRef = useRef<HTMLDivElement>(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,
Expand Down Expand Up @@ -167,7 +158,11 @@ function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: Ref
<div
{...mergeProps(DOMProps, renderProps, tooltipProps)}
ref={props.tooltipRef}
style={{...overlayProps.style, ...renderProps.style}}
style={{
...overlayProps.style,
'--trigger-origin': triggerOrigin ? `${triggerOrigin.x}px ${triggerOrigin.y}px` : undefined,
...renderProps.style
} as CSSProperties}
data-placement={placement ?? undefined}
data-entering={isEntering || undefined}
data-exiting={props.isExiting || undefined}>
Expand Down
28 changes: 24 additions & 4 deletions packages/react-aria-components/stories/Popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,47 @@
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<typeof Popover>;

export type PopoverStory = StoryFn<typeof Popover>;

export const PopoverExample: PopoverStory = () => (
export const PopoverExample: PopoverStory = (args) => (
<DialogTrigger>
<Button>Open popover</Button>
<Popover
placement="bottom start"
{...args}
className={styles.popover}
style={{
background: 'Canvas',
color: 'CanvasText',
border: '1px solid gray',
padding: 30,
zIndex: 5
}}>
{!(args as any).hideArrow && <OverlayArrow style={{display: 'flex'}}>
<svg width="12" height="12" viewBox="0 0 12 12" style={{display: 'block'}}>
<path d="M0 0L6 6L12 0" fill="white" strokeWidth={1} stroke="gray" />
</svg>
</OverlayArrow>}
<Dialog>
{({close}) => (
<form style={{display: 'flex', flexDirection: 'column'}}>
Expand Down
Loading