Skip to content

Commit 3628440

Browse files
authored
feat: Add support for origin-aware overlay animations (#8681)
1 parent 155e361 commit 3628440

File tree

11 files changed

+153
-47
lines changed

11 files changed

+153
-47
lines changed

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface PositionResult {
6767
position: Position,
6868
arrowOffsetLeft?: number,
6969
arrowOffsetTop?: number,
70+
triggerOrigin: {x: number, y: number},
7071
maxHeight: number,
7172
placement: PlacementAxis
7273
}
@@ -419,7 +420,8 @@ export function calculatePositionInternal(
419420
// 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
420421
// 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"
421422
// is positioned after the margin of its parent (aka overlay) so we need to subtract it to get the proper coordinate transform
422-
let preferredArrowPosition = childOffset[crossAxis] + .5 * childOffset[crossSize] - position[crossAxis]! - margins[AXIS[crossAxis]];
423+
let origin = childOffset[crossAxis] - position[crossAxis]! - margins[AXIS[crossAxis]];
424+
let preferredArrowPosition = origin + .5 * childOffset[crossSize];
423425

424426
// Min/Max position limits for the arrow with respect to the overlay
425427
const arrowMinPosition = arrowSize / 2 + arrowBoundaryOffset;
@@ -436,12 +438,30 @@ export function calculatePositionInternal(
436438
const arrowPositionOverlappingChild = clamp(preferredArrowPosition, arrowOverlappingChildMinEdge, arrowOverlappingChildMaxEdge);
437439
arrowPosition[crossAxis] = clamp(arrowPositionOverlappingChild, arrowMinPosition, arrowMaxPosition);
438440

441+
// If there is an arrow, use that as the origin so that animations are smooth.
442+
// Otherwise use the target edge.
443+
({placement, crossPlacement} = placementInfo);
444+
if (arrowSize) {
445+
origin = arrowPosition[crossAxis];
446+
} else if (crossPlacement === 'right') {
447+
origin += childOffset[crossSize];
448+
} else if (crossPlacement === 'center') {
449+
origin += childOffset[crossSize] / 2;
450+
}
451+
452+
let crossOrigin = placement === 'left' || placement === 'top' ? overlaySize[size] : 0;
453+
let triggerOrigin = {
454+
x: placement === 'top' || placement === 'bottom' ? origin : crossOrigin,
455+
y: placement === 'left' || placement === 'right' ? origin : crossOrigin
456+
};
457+
439458
return {
440459
position,
441460
maxHeight: maxHeight,
442461
arrowOffsetLeft: arrowPosition.left,
443462
arrowOffsetTop: arrowPosition.top,
444-
placement: placementInfo.placement
463+
placement,
464+
triggerOrigin
445465
};
446466
}
447467

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export interface AriaPositionProps extends PositionProps {
3737
* The ref for the overlay element.
3838
*/
3939
overlayRef: RefObject<Element | null>,
40+
/**
41+
* The ref for the arrow element.
42+
*/
43+
arrowRef?: RefObject<Element | null>,
4044
/**
4145
* A ref for the scrollable region within the overlay.
4246
* @default overlayRef
@@ -68,6 +72,8 @@ export interface PositionAria {
6872
arrowProps: DOMAttributes,
6973
/** Placement of the overlay with respect to the overlay trigger. */
7074
placement: PlacementAxis | null,
75+
/** The origin of the target in the overlay's coordinate system. Useful for animations. */
76+
triggerOrigin: {x: number, y: number} | null,
7177
/** Updates the position of the overlay. */
7278
updatePosition(): void
7379
}
@@ -86,9 +92,10 @@ let visualViewport = typeof document !== 'undefined' ? window.visualViewport : n
8692
export function useOverlayPosition(props: AriaPositionProps): PositionAria {
8793
let {direction} = useLocale();
8894
let {
89-
arrowSize = 0,
95+
arrowSize,
9096
targetRef,
9197
overlayRef,
98+
arrowRef,
9299
scrollRef = overlayRef,
93100
placement = 'bottom' as Placement,
94101
containerPadding = 12,
@@ -109,6 +116,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
109116
placement,
110117
overlayRef.current,
111118
targetRef.current,
119+
arrowRef?.current,
112120
scrollRef.current,
113121
containerPadding,
114122
shouldFlip,
@@ -141,6 +149,12 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
141149
return;
142150
}
143151

152+
// Don't update while the overlay is animating.
153+
// Things like scale animations can mess up positioning by affecting the overlay's computed size.
154+
if (overlayRef.current.getAnimations?.().length > 0) {
155+
return;
156+
}
157+
144158
// Determine a scroll anchor based on the focused element.
145159
// This stores the offset of the anchor element from the scroll container
146160
// so it can be restored after repositioning. This way if the overlay height
@@ -181,7 +195,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
181195
offset,
182196
crossOffset,
183197
maxHeight,
184-
arrowSize,
198+
arrowSize: arrowSize ?? arrowRef?.current?.getBoundingClientRect().width ?? 0,
185199
arrowBoundaryOffset
186200
});
187201

@@ -287,6 +301,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
287301
}
288302
},
289303
placement: position?.placement ?? null,
304+
triggerOrigin: position?.triggerOrigin ?? null,
290305
arrowProps: {
291306
'aria-hidden': 'true',
292307
role: 'presentation',

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export interface AriaPopoverProps extends Omit<AriaPositionProps, 'isOpen' | 'on
2929
* The ref for the popover element.
3030
*/
3131
popoverRef: RefObject<Element | null>,
32+
/** A ref for the popover arrow element. */
33+
arrowRef?: RefObject<Element | null>,
3234
/**
3335
* An optional ref for a group of popovers, e.g. submenus.
3436
* When provided, this element is used to detect outside interactions
@@ -70,7 +72,9 @@ export interface PopoverAria {
7072
/** Props to apply to the underlay element, if any. */
7173
underlayProps: DOMAttributes,
7274
/** Placement of the popover with respect to the trigger. */
73-
placement: PlacementAxis | null
75+
placement: PlacementAxis | null,
76+
/** The origin of the target in the overlay's coordinate system. Useful for animations. */
77+
triggerOrigin: {x: number, y: number} | null
7478
}
7579

7680
/**
@@ -102,7 +106,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
102106
groupRef ?? popoverRef
103107
);
104108

105-
let {overlayProps: positionProps, arrowProps, placement} = useOverlayPosition({
109+
let {overlayProps: positionProps, arrowProps, placement, triggerOrigin: origin} = useOverlayPosition({
106110
...otherProps,
107111
targetRef: triggerRef,
108112
overlayRef: popoverRef,
@@ -128,6 +132,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
128132
popoverProps: mergeProps(overlayProps, positionProps),
129133
arrowProps,
130134
underlayProps,
131-
placement
135+
placement,
136+
triggerOrigin: origin
132137
};
133138
}

packages/@react-aria/overlays/test/calculatePosition.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,19 @@ describe('calculatePosition', function () {
119119
pos.top = expected[1];
120120
}
121121

122+
let calculatedPlacement = flip ? FLIPPED_DIRECTION[placementAxis] : placementAxis;
123+
// 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
124+
let maxHeight = expected[4] - (placementAxis !== 'top' && placementCrossAxis !== 'bottom' ? providerOffset : 0);
122125
const expectedPosition = {
123126
position: pos,
124127
arrowOffsetLeft: expected[2],
125128
arrowOffsetTop: expected[3],
126-
// 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
127-
maxHeight: expected[4] - (placementAxis !== 'top' && placementCrossAxis !== 'bottom' ? providerOffset : 0),
128-
placement: flip ? FLIPPED_DIRECTION[placementAxis] : placementAxis
129+
maxHeight,
130+
placement: calculatedPlacement,
131+
triggerOrigin: {
132+
x: expected[2] ?? (calculatedPlacement === 'left' ? overlaySize.width : 0),
133+
y: expected[3] ?? (calculatedPlacement === 'top' ? Math.min(overlaySize.height, maxHeight) : 0)
134+
}
129135
};
130136

131137
const container = createElementWithDimensions('div', containerDimensions);

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -438,15 +438,17 @@ The `className` and `style` props also accept functions which receive states for
438438
</OverlayArrow>
439439
```
440440

441-
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.
441+
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.
442442

443443
```css render=false
444444
.react-aria-Popover {
445-
transition: opacity 300ms;
445+
transition: opacity 300ms, scale 300ms;
446+
transform-origin: var(--trigger-origin);
446447

447448
&[data-entering],
448449
&[data-exiting] {
449450
opacity: 0;
451+
scale: 0.85;
450452
}
451453
}
452454
```
@@ -459,7 +461,7 @@ A `Popover` can be targeted with the `.react-aria-Popover` CSS selector, or by o
459461

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

462-
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.
464+
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.
463465

464466
```css render=false
465467
.react-aria-Popover[data-trigger=DialogTrigger] {

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,15 +396,17 @@ The `className` and `style` props also accept functions which receive states for
396396
</OverlayArrow>
397397
```
398398

399-
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.
399+
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.
400400

401401
```css render=false
402402
.react-aria-Tooltip {
403-
transition: opacity 300ms;
403+
transition: opacity 300ms, scale 300ms;
404+
transform-origin: var(--trigger-origin);
404405

405406
&[data-entering],
406407
&[data-exiting] {
407408
opacity: 0;
409+
scale: 0.85;
408410
}
409411
}
410412
```

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,20 +147,14 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts
147147
// Calculate the arrow size internally (and remove props.arrowSize from PopoverProps)
148148
// Referenced from: packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx
149149
let arrowRef = useRef<HTMLDivElement>(null);
150-
let [arrowWidth, setArrowWidth] = useState(0);
151150
let containerRef = useRef<HTMLDivElement | null>(null);
152151
let groupCtx = useContext(PopoverGroupContext);
153152
let isSubPopover = groupCtx && props.trigger === 'SubmenuTrigger';
154-
useLayoutEffect(() => {
155-
if (arrowRef.current && state.isOpen) {
156-
setArrowWidth(arrowRef.current.getBoundingClientRect().width);
157-
}
158-
}, [state.isOpen, arrowRef]);
159153

160-
let {popoverProps, underlayProps, arrowProps, placement} = usePopover({
154+
let {popoverProps, underlayProps, arrowProps, placement, triggerOrigin} = usePopover({
161155
...props,
162156
offset: props.offset ?? 8,
163-
arrowSize: arrowWidth,
157+
arrowRef,
164158
// If this is a submenu/subdialog, use the root popover's container
165159
// to detect outside interaction and add aria-hidden.
166160
groupRef: isSubPopover ? groupCtx! : containerRef
@@ -207,7 +201,12 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts
207201
return children;
208202
}, [renderProps.children, clearContexts]);
209203

210-
let style = {...popoverProps.style, ...renderProps.style};
204+
let style = {
205+
...popoverProps.style,
206+
'--trigger-origin': triggerOrigin ? `${triggerOrigin.x}px ${triggerOrigin.y}px` : undefined,
207+
...renderProps.style
208+
};
209+
211210
let overlay = (
212211
<div
213212
{...mergeProps(filterDOMProps(props, {global: true}), popoverProps)}

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

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
import {AriaLabelingProps, FocusableElement, forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/shared';
1414
import {AriaPositionProps, mergeProps, OverlayContainer, Placement, PlacementAxis, PositionProps, useOverlayPosition, useTooltip, useTooltipTrigger} from 'react-aria';
1515
import {ContextValue, Provider, RenderProps, useContextProps, useRenderProps} from './utils';
16-
import {filterDOMProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils';
16+
import {filterDOMProps, useEnterAnimation, useExitAnimation} from '@react-aria/utils';
1717
import {FocusableProvider} from '@react-aria/focus';
1818
import {OverlayArrowContext} from './OverlayArrow';
1919
import {OverlayTriggerProps, TooltipTriggerProps, TooltipTriggerState, useTooltipTriggerState} from 'react-stately';
20-
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useRef, useState} from 'react';
20+
import React, {createContext, CSSProperties, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useRef} from 'react';
2121

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

122122
function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: RefObject<HTMLDivElement | null>}) {
123123
let state = useContext(TooltipTriggerStateContext)!;
124-
125-
// Calculate the arrow size internally
126-
// Referenced from: packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx
127124
let arrowRef = useRef<HTMLDivElement>(null);
128-
let [arrowWidth, setArrowWidth] = useState(0);
129-
useLayoutEffect(() => {
130-
if (arrowRef.current && state.isOpen) {
131-
setArrowWidth(arrowRef.current.getBoundingClientRect().width);
132-
}
133-
}, [state.isOpen, arrowRef]);
134125

135-
let {overlayProps, arrowProps, placement} = useOverlayPosition({
126+
let {overlayProps, arrowProps, placement, triggerOrigin} = useOverlayPosition({
136127
placement: props.placement || 'top',
137128
targetRef: props.triggerRef!,
138129
overlayRef: props.tooltipRef,
130+
arrowRef,
139131
offset: props.offset,
140132
crossOffset: props.crossOffset,
141133
isOpen: state.isOpen,
142-
arrowSize: arrowWidth,
143134
arrowBoundaryOffset: props.arrowBoundaryOffset,
144135
shouldFlip: props.shouldFlip,
145136
containerPadding: props.containerPadding,
@@ -167,7 +158,11 @@ function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: Ref
167158
<div
168159
{...mergeProps(DOMProps, renderProps, tooltipProps)}
169160
ref={props.tooltipRef}
170-
style={{...overlayProps.style, ...renderProps.style}}
161+
style={{
162+
...overlayProps.style,
163+
'--trigger-origin': triggerOrigin ? `${triggerOrigin.x}px ${triggerOrigin.y}px` : undefined,
164+
...renderProps.style
165+
} as CSSProperties}
171166
data-placement={placement ?? undefined}
172167
data-entering={isEntering || undefined}
173168
data-exiting={props.isExiting || undefined}>

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,27 +13,47 @@
1313
import {Button, Dialog, DialogTrigger, Heading, OverlayArrow, Popover} from 'react-aria-components';
1414
import {Meta, StoryFn, StoryObj} from '@storybook/react';
1515
import React, {JSX, useEffect, useRef, useState} from 'react';
16-
import './styles.css';
16+
import styles from './styles.css';
1717

1818
export default {
1919
title: 'React Aria Components/Popover',
20-
component: Popover
20+
component: Popover,
21+
args: {
22+
placement: 'bottom start',
23+
hideArrow: false
24+
},
25+
argTypes: {
26+
placement: {
27+
control: 'select',
28+
options: ['bottom', 'bottom left', 'bottom right', 'bottom start', 'bottom end',
29+
'top', 'top left', 'top right', 'top start', 'top end',
30+
'left', 'left top', 'left bottom', 'start', 'start top', 'start bottom',
31+
'right', 'right top', 'right bottom', 'end', 'end top', 'end bottom'
32+
]
33+
}
34+
}
2135
} as Meta<typeof Popover>;
2236

2337
export type PopoverStory = StoryFn<typeof Popover>;
2438

25-
export const PopoverExample: PopoverStory = () => (
39+
export const PopoverExample: PopoverStory = (args) => (
2640
<DialogTrigger>
2741
<Button>Open popover</Button>
2842
<Popover
29-
placement="bottom start"
43+
{...args}
44+
className={styles.popover}
3045
style={{
3146
background: 'Canvas',
3247
color: 'CanvasText',
3348
border: '1px solid gray',
3449
padding: 30,
3550
zIndex: 5
3651
}}>
52+
{!(args as any).hideArrow && <OverlayArrow style={{display: 'flex'}}>
53+
<svg width="12" height="12" viewBox="0 0 12 12" style={{display: 'block'}}>
54+
<path d="M0 0L6 6L12 0" fill="white" strokeWidth={1} stroke="gray" />
55+
</svg>
56+
</OverlayArrow>}
3757
<Dialog>
3858
{({close}) => (
3959
<form style={{display: 'flex', flexDirection: 'column'}}>

0 commit comments

Comments
 (0)