From 0aaf12ad5a0cc900fae4da3d45f1ccdb590c144a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Sat, 3 Oct 2026 20:57:01 +0200 Subject: [PATCH] feat: OverlayPositioner with default and CSS anchor positioning Moves overlay placement in useOverlayPosition behind an OverlayPositioner interface (subscribe/update), built on the DOMResizableBox and DOMAnchorBox primitives. - DefaultOverlayPositioner: calculatePosition, observing target, overlay, viewport and custom boundary boxes. - AnchorOverlayPositioner: native CSS anchor positioning (position-area, position-try-fallbacks), with the applied placement read back for the arrow and origin-aware animations. - Popover stories for both positioners in a resizable container. Co-Authored-By: Claude Opus 5.5 --- .../stories/Popover.stories.tsx | 170 ++++++++--- packages/react-aria/exports/index.ts | 6 +- .../react-aria/exports/useOverlayPosition.ts | 6 +- .../src/overlays/AnchorOverlayPositioner.ts | 275 ++++++++++++++++++ .../src/overlays/DefaultOverlayPositioner.ts | 119 ++++++++ .../src/overlays/calculatePosition.ts | 4 +- .../src/overlays/useOverlayPosition.ts | 135 ++++----- 7 files changed, 587 insertions(+), 128 deletions(-) create mode 100644 packages/react-aria/src/overlays/AnchorOverlayPositioner.ts create mode 100644 packages/react-aria/src/overlays/DefaultOverlayPositioner.ts diff --git a/packages/react-aria-components/stories/Popover.stories.tsx b/packages/react-aria-components/stories/Popover.stories.tsx index 67159dbbca4..80e6aa8c6a5 100644 --- a/packages/react-aria-components/stories/Popover.stories.tsx +++ b/packages/react-aria-components/stories/Popover.stories.tsx @@ -10,15 +10,17 @@ * governing permissions and limitations under the License. */ +import {AnchorOverlayPositioner, DefaultOverlayPositioner} from 'react-aria/useOverlayPosition'; import {Button} from '../src/Button'; import {Dialog, DialogTrigger} from '../src/Dialog'; import {Heading} from '../src/Heading'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {OverlayArrow} from '../src/OverlayArrow'; -import {Popover} from '../src/Popover'; -import React, {JSX, useEffect, useRef, useState} from 'react'; +import {Popover, PopoverProps} from '../src/Popover'; +import React, {JSX, ReactNode, useEffect, useRef, useState} from 'react'; import styles from './styles.css'; +import {UNSAFE_PortalProvider} from 'react-aria/PortalProvider'; export default { title: 'React Aria Components/Popover', @@ -64,45 +66,56 @@ export default { export type PopoverStory = StoryFn; -export const PopoverExample: PopoverStory = args => ( - - - - {!(args as any).hideArrow && ( - - - - - - )} - - {({close}) => ( -
- Sign up - - - -
+interface PopoverExampleProps extends PopoverProps { + animation?: 'transition' | 'animation' | 'animation-delayed'; + hideArrow?: boolean; +} + +function PopoverExampleRender(args: PopoverExampleProps): JSX.Element { + return ( + + + + {!args.hideArrow && ( + + + + + )} -
-
-
-); + + {({close}) => ( +
+ Sign up + + + +
+ )} +
+ + + ); +} + +export const PopoverExample: StoryObj = { + render: args => +}; const COUNTDOWN = 5000; @@ -609,3 +622,80 @@ export const ScrollingBoundaryContainer: StoryObj ReactNode; +} + +function PositionerContainer(props: PositionerContainerProps): JSX.Element { + let [container, setContainer] = useState(null); + + return ( +
+ {container && props.children(container)} +
+ ); +} + +export const DefaultPositionerExample: StoryObj = { + render: args => ( + + {container => ( + element !== container} + /> + )} + + ), + args: { + placement: 'bottom' + }, + argTypes: { + positioner: {table: {disable: true}} + } +}; + +export const AnchorPositionerExample: StoryObj = { + render: args => ( + + {container => ( + container}> + element !== container} + /> + + )} + + ), + args: { + placement: 'bottom' + }, + argTypes: { + crossOffset: {table: {disable: true}}, + positioner: {table: {disable: true}} + } +}; diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 2c8a2d20d1f..c438941496b 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -117,6 +117,8 @@ export {Overlay} from '../src/overlays/Overlay'; export {useModalOverlay} from '../src/overlays/useModalOverlay'; export {useOverlay} from '../src/overlays/useOverlay'; export {useOverlayPosition} from '../src/overlays/useOverlayPosition'; +export {DefaultOverlayPositioner} from '../src/overlays/DefaultOverlayPositioner'; +export {AnchorOverlayPositioner} from '../src/overlays/AnchorOverlayPositioner'; export {useOverlayTrigger} from '../src/overlays/useOverlayTrigger'; export {usePopover} from '../src/overlays/usePopover'; export {usePreventScroll} from '../src/overlays/usePreventScroll'; @@ -422,7 +424,9 @@ export type { PlacementAxis, PositionProps, Axis, - SizeAxis + SizeAxis, + OverlayPositioner, + SubscribeOpts } from '../src/overlays/useOverlayPosition'; export type {DismissButtonProps} from '../src/overlays/DismissButton'; export type {OverlayProps} from '../src/overlays/Overlay'; diff --git a/packages/react-aria/exports/useOverlayPosition.ts b/packages/react-aria/exports/useOverlayPosition.ts index fe49c6cd4e3..857b115b30c 100644 --- a/packages/react-aria/exports/useOverlayPosition.ts +++ b/packages/react-aria/exports/useOverlayPosition.ts @@ -6,5 +6,9 @@ export { useOverlayPosition, type PositionAria, type Axis, - type SizeAxis + type SizeAxis, + type OverlayPositioner, + type SubscribeOpts } from '../src/overlays/useOverlayPosition'; +export {DefaultOverlayPositioner} from '../src/overlays/DefaultOverlayPositioner'; +export {AnchorOverlayPositioner} from '../src/overlays/AnchorOverlayPositioner'; diff --git a/packages/react-aria/src/overlays/AnchorOverlayPositioner.ts b/packages/react-aria/src/overlays/AnchorOverlayPositioner.ts new file mode 100644 index 00000000000..8de4447cd16 --- /dev/null +++ b/packages/react-aria/src/overlays/AnchorOverlayPositioner.ts @@ -0,0 +1,275 @@ +/* + * Copyright 2026 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. + */ + +import {clamp} from 'react-stately/private/utils/number'; +import {DOMAnchorBox, DOMBox, DOMResizableBox} from '../utils/layout'; +import {getContainingElement} from '../utils/layoutHelpers'; +import {getOwnerDocument, getOwnerWindow} from '../utils/domHelpers'; +import {OverlayPositioner, PlacementAxis, SizeAxis, SubscribeOpts} from './useOverlayPosition'; +import {PositionOpts, PositionResult} from './calculatePosition'; + +const FLIPPED_DIRECTION: Record = { + top: 'bottom', + bottom: 'top', + left: 'right', + right: 'left' +}; + +const SHRINK_FALLBACK = Object.freeze(` + @position-try --react-aria-shrink { + max-height: min( + calc(100% - var(--react-aria-container-padding, 0px)), + var(--react-aria-max-height, 100vh) + ); + } +`); + +/** + * Places an overlay with native CSS anchor positioning. + * + * The browser flips and shifts the overlay and moves it on scroll, but does not report the result. + * A `DOMAnchorBox` observes the overlay, and the placement is read back for the arrow, + * `data-placement` and origin-aware animations. + * + * Not supported yet: `crossOffset`, `boundaryElement` and `getTargetRect`. The overlay always flips + * against its containing block. An overflowing start or end aligned overlay stays within the + * containing block, but ignores the container padding. + */ +export class AnchorOverlayPositioner implements OverlayPositioner { + private static count = 0; + private static documents = new WeakSet(); + + private anchorNames = new WeakMap(); + + readonly position = 'fixed'; + + update(opts: PositionOpts): PositionResult | null { + let target = opts.targetNode as HTMLElement; + let overlay = opts.overlayNode as HTMLElement; + let ownerWindow = getOwnerWindow(overlay); + + // useOverlayPosition already translates start and end into left and right. + let placements = opts.placement.split(' ') as PlacementAxis[]; + let placement = placements[0]; + let crossPlacement = placements[1] ?? 'center'; + + let isVertical = placement === 'top' || placement === 'bottom'; + let flip = isVertical ? 'flip-block' : 'flip-inline'; + + // A single keyword spans the full cross axis and centers on the anchor. + // A "span-*" keyword aligns the overlay to one target edge and grows away from it. + let spanSide = FLIPPED_DIRECTION[crossPlacement]; + let area = spanSide != null ? `${placement} span-${spanSide}` : placement; + + // Prefer the requested side at full height, then the opposite side, then shrink. + let fallbacks = opts.shouldFlip + ? `${flip}, --react-aria-shrink, --react-aria-shrink ${flip}` + : '--react-aria-shrink'; + + this.connect(target); + + // Write styles directly so the overlay is placed without a second render. + // This also clears the styles that disconnect() kept when the overlay last closed. + overlay.style.removeProperty('max-height'); + overlay.style.setProperty('position-anchor', this.anchorNames.get(target)!); + overlay.style.setProperty('position-area', area); + overlay.style.setProperty('position-try-fallbacks', fallbacks); + overlay.style.setProperty('--react-aria-container-padding', `${opts.padding}px`); + + if (opts.maxHeight != null) { + overlay.style.setProperty('--react-aria-max-height', `${opts.maxHeight}px`); + } else { + overlay.style.removeProperty('--react-aria-max-height'); + } + + // Insets shrink the position-area cell, so offset and container padding are insets. They flip + // with the placement, and margins stay with the overlay's own styles. The far-side inset stays + // auto, because Chromium does not reliably re-evaluate fallbacks on scroll when it is set. + // The shrink fallback applies that padding instead. + let position: PositionResult['position'] = {}; + for (let side of ['top', 'right', 'bottom', 'left']) { + let value = side === FLIPPED_DIRECTION[placement] ? opts.offset : opts.padding; + + if (side === placement) { + overlay.style.removeProperty(side); + } else { + position[side] = value; + overlay.style.setProperty(side, `${value}px`); + } + } + + // Reading rects forces layout, which resolves the applied fallback. + // Measure without transforms, so an entering animation does not skew the arrow. + let targetBox = new DOMBox(target, {transform: false}); + let overlayBox = new DOMBox(overlay, {transform: false}); + + let targetRect = targetBox.boundingRect; + let overlayRect = overlayBox.boundingRect; + + // The browser does not report the applied fallback, so read it back from position-area. + let style = ownerWindow.getComputedStyle(overlay); + let positionArea = style.getPropertyValue('position-area'); + let areaTokens = positionArea.split(' '); + + let appliedSide = areaTokens.find(token => token in FLIPPED_DIRECTION); + let applied = (appliedSide as PlacementAxis | undefined) ?? placement; + + let isAppliedVertical = applied === 'top' || applied === 'bottom'; + let start: 'x' | 'y' = isAppliedVertical ? 'x' : 'y'; + let size: SizeAxis = isAppliedVertical ? 'width' : 'height'; + let crossSize: SizeAxis = isAppliedVertical ? 'height' : 'width'; + + // Express the target's edges and center in the overlay's coordinate space. + let targetStart = targetRect[start] - overlayRect[start]; + let targetEnd = targetStart + targetRect[size]; + let targetCenter = targetStart + targetRect[size] / 2; + + // Clamp the arrow like calculatePosition does, so it stays within both boxes. + let half = opts.arrowSize / 2; + let boundaryOffset = opts.arrowBoundaryOffset ?? 0; + + let arrow = clamp(targetCenter, targetStart + half, targetEnd - half); + arrow = clamp(arrow, half + boundaryOffset, overlayRect[size] - half - boundaryOffset); + + let anchorPoint = targetStart; + if (opts.arrowSize) { + anchorPoint = arrow; + } else if (crossPlacement === 'right' || crossPlacement === 'bottom') { + anchorPoint = targetEnd; + } else if (crossPlacement === 'center') { + anchorPoint = targetCenter; + } + + let isAppliedStart = applied === 'top' || applied === 'left'; + let crossAnchorPoint = isAppliedStart ? overlayRect[crossSize] : 0; + + return { + position, + arrowOffsetLeft: isAppliedVertical ? arrow : undefined, + arrowOffsetTop: isAppliedVertical ? undefined : arrow, + placement: applied, + triggerAnchorPoint: { + x: isAppliedVertical ? anchorPoint : crossAnchorPoint, + y: isAppliedVertical ? crossAnchorPoint : anchorPoint + } + }; + } + + subscribe(opts: SubscribeOpts, updatePosition: () => void): () => void { + let target = opts.targetNode as HTMLElement; + let overlay = opts.overlayNode as HTMLElement; + + let targetBox = new DOMResizableBox(target); + let overlayBox = new DOMAnchorBox(overlay); + + targetBox.addEventListener('react-aria-boxchange', updatePosition); + overlayBox.addEventListener('react-aria-boxchange', updatePosition); + + return () => { + targetBox.removeEventListener('react-aria-boxchange', updatePosition); + overlayBox.removeEventListener('react-aria-boxchange', updatePosition); + + this.disconnect(target, overlay); + }; + } + + private connect(target: HTMLElement) { + let ownerWindow = getOwnerWindow(target); + let ownerDocument = getOwnerDocument(target); + + if (!AnchorOverlayPositioner.documents.has(ownerDocument)) { + let sheet = new ownerWindow.CSSStyleSheet(); + + sheet.replaceSync(SHRINK_FALLBACK.trim()); + + ownerDocument.adoptedStyleSheets.push(sheet); + AnchorOverlayPositioner.documents.add(ownerDocument); + } + + if (this.anchorNames.has(target)) { + return; + } + + let anchorName = `--react-aria-overlay-${++AnchorOverlayPositioner.count}`; + + // Append the name like DOMAnchorBox does, so other anchor names on the target survive. + let style = ownerWindow.getComputedStyle(target); + let currentName = style.getPropertyValue('anchor-name'); + let currentNames = currentName.split(',').map(name => name.trim()); + + let filtered = currentNames.filter(name => name && name !== 'none'); + + target.style.setProperty('anchor-name', filtered.concat(anchorName).join(', ')); + + this.anchorNames.set(target, anchorName); + } + + private disconnect(target: HTMLElement, overlay: HTMLElement) { + let ownerWindow = getOwnerWindow(overlay); + let anchorName = this.anchorNames.get(target); + + if (anchorName == null) { + return; + } + + // Freeze the overlay at static coordinates before the anchor name is removed. + // This keeps exit animations in place. + let style = ownerWindow.getComputedStyle(overlay); + + // A fixed overlay has no offset parent, but an ancestor may still be its containing block. + let containingBlock = getContainingElement(overlay); + + let overlayBox = new DOMBox(overlay, {transform: false}); + let overlayRect = overlayBox.boundingRect; + + let marginTop = parseFloat(style.marginTop) || 0; + let marginLeft = parseFloat(style.marginLeft) || 0; + + let top = overlayRect.top - marginTop; + let left = overlayRect.left - marginLeft; + + if (containingBlock != null) { + let containingBox = new DOMBox(containingBlock, {model: 'padding-box', transform: false}); + let containingRect = containingBox.boundingRect; + + top += containingBlock.scrollTop - containingRect.top; + left += containingBlock.scrollLeft - containingRect.left; + } + + // Keep the max height from the shrink fallback, because removing the fallbacks resets it. + if (style.maxHeight !== 'none') { + overlay.style.setProperty('max-height', style.maxHeight); + } + + overlay.style.removeProperty('position-anchor'); + overlay.style.removeProperty('position-area'); + overlay.style.removeProperty('position-try-fallbacks'); + overlay.style.removeProperty('right'); + overlay.style.removeProperty('bottom'); + + overlay.style.setProperty('top', `${top}px`); + overlay.style.setProperty('left', `${left}px`); + + let currentName = target.style.getPropertyValue('anchor-name'); + let currentNames = currentName.split(',').map(name => name.trim()); + + let filtered = currentNames.filter(name => name && name !== anchorName); + + if (filtered.length > 0) { + target.style.setProperty('anchor-name', filtered.join(', ')); + } else { + target.style.removeProperty('anchor-name'); + } + + this.anchorNames.delete(target); + } +} diff --git a/packages/react-aria/src/overlays/DefaultOverlayPositioner.ts b/packages/react-aria/src/overlays/DefaultOverlayPositioner.ts new file mode 100644 index 00000000000..351f8317861 --- /dev/null +++ b/packages/react-aria/src/overlays/DefaultOverlayPositioner.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2026 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. + */ + +import {calculatePosition, PositionOpts, PositionResult} from './calculatePosition'; +import {DOMAnchorBox, DOMResizableBox} from '../utils/layout'; +import {getActiveElement, isFocusWithin} from '../utils/shadowdom/DOMFunctions'; +import {getOwnerDocument, getOwnerWindow} from '../utils/domHelpers'; +import {OverlayPositioner, SubscribeOpts} from './useOverlayPosition'; + +interface ScrollAnchor { + type: 'top' | 'bottom'; + offset: number; +} + +/** + * Calculates the position of an overlay in JavaScript, and writes it to the overlay's styles. + */ +export class DefaultOverlayPositioner implements OverlayPositioner { + readonly position = 'absolute'; + + update(opts: PositionOpts): PositionResult | null { + let overlay = opts.overlayNode as HTMLElement; + let scrollNode = opts.scrollNode; + let activeElement = getActiveElement(); + + // Record the offset of the focused element from the nearer scroll container edge. Restoring it + // keeps the focused element visually in place when the overlay height changes. + let anchor: ScrollAnchor | null = null; + + if (activeElement != null && isFocusWithin(scrollNode)) { + let anchorRect = activeElement.getBoundingClientRect(); + let scrollRect = scrollNode.getBoundingClientRect(); + + let topOffset = anchorRect.top - scrollRect.top; + let bottomOffset = anchorRect.bottom - scrollRect.bottom; + + anchor = + topOffset > scrollRect.height / 2 + ? {type: 'bottom', offset: bottomOffset} + : {type: 'top', offset: topOffset}; + } + + // Reset the previous max height if the user does not set one. + // RAC collections populate after a second render and need a new max height then. + if (!opts.maxHeight) { + let viewportHeight = window.visualViewport?.height ?? window.innerHeight; + + overlay.style.top = '0px'; + overlay.style.bottom = ''; + overlay.style.maxHeight = `${viewportHeight}px`; + } + + let result = calculatePosition(opts); + + // Write styles directly so positioning happens without a second render. + // This way, autoFocus scrolling and preventScroll for popovers need no delay. + for (let side of ['top', 'right', 'bottom', 'left']) { + let value = result.position[side]; + overlay.style[side] = value != null ? `${value}px` : ''; + } + + overlay.style.maxHeight = result.maxHeight != null ? `${result.maxHeight}px` : ''; + + if (anchor != null && activeElement != null) { + let anchorRect = activeElement.getBoundingClientRect(); + let scrollRect = scrollNode.getBoundingClientRect(); + let offset = anchorRect[anchor.type] - scrollRect[anchor.type]; + + scrollNode.scrollTop += offset - anchor.offset; + } + + return result; + } + + subscribe(opts: SubscribeOpts, updatePosition: () => void): () => void { + let target = opts.targetNode as HTMLElement; + let overlay = opts.overlayNode; + let boundary = opts.boundaryElement; + + let ownerWindow = getOwnerWindow(target); + let ownerDocument = getOwnerDocument(target); + + // Fall back to window resize events in environments without ResizeObserver, e.g. jsdom. + if (typeof ownerWindow.ResizeObserver === 'undefined') { + ownerWindow.addEventListener('resize', updatePosition, false); + return () => ownerWindow.removeEventListener('resize', updatePosition, false); + } + + // The viewport box covers the default boundary, so observe only a custom one. + let isCustomBoundary = + boundary !== ownerDocument.body && boundary !== ownerDocument.documentElement; + + let targetBox = new DOMAnchorBox(target); + let overlayBox = new DOMResizableBox(overlay); + let viewportBox = new DOMResizableBox(ownerDocument); + let boundaryBox = isCustomBoundary ? new DOMResizableBox(boundary) : null; + + targetBox.addEventListener('react-aria-boxchange', updatePosition); + overlayBox.addEventListener('react-aria-boxchange', updatePosition); + viewportBox.addEventListener('react-aria-boxchange', updatePosition); + boundaryBox?.addEventListener('react-aria-boxchange', updatePosition); + + return () => { + targetBox.removeEventListener('react-aria-boxchange', updatePosition); + overlayBox.removeEventListener('react-aria-boxchange', updatePosition); + viewportBox.removeEventListener('react-aria-boxchange', updatePosition); + boundaryBox?.removeEventListener('react-aria-boxchange', updatePosition); + }; + } +} diff --git a/packages/react-aria/src/overlays/calculatePosition.ts b/packages/react-aria/src/overlays/calculatePosition.ts index bf6cea3335d..d8c2aed5779 100644 --- a/packages/react-aria/src/overlays/calculatePosition.ts +++ b/packages/react-aria/src/overlays/calculatePosition.ts @@ -50,7 +50,7 @@ interface Offset { height: number; } -interface PositionOpts { +export interface PositionOpts { arrowSize: number; placement: Placement; targetNode: Element; @@ -73,7 +73,7 @@ export interface PositionResult { arrowOffsetLeft?: number; arrowOffsetTop?: number; triggerAnchorPoint: {x: number; y: number}; - maxHeight: number; + maxHeight?: number; placement: PlacementAxis; } diff --git a/packages/react-aria/src/overlays/useOverlayPosition.ts b/packages/react-aria/src/overlays/useOverlayPosition.ts index 0d1e410c048..988126b1c0f 100644 --- a/packages/react-aria/src/overlays/useOverlayPosition.ts +++ b/packages/react-aria/src/overlays/useOverlayPosition.ts @@ -11,15 +11,14 @@ */ import {addEvent} from '../utils/domHelpers'; -import {calculatePosition, getRect, PositionResult} from './calculatePosition'; +import {DefaultOverlayPositioner} from './DefaultOverlayPositioner'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {getActiveElement, isFocusWithin} from '../utils/shadowdom/DOMFunctions'; import {getPropagationTargets} from '../utils/shadowdom/DOMFunctions'; +import {getRect, PositionOpts, PositionResult} from './calculatePosition'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; -import {useResizeObserver} from '../utils/useResizeObserver'; export type Placement = | 'bottom' @@ -151,6 +150,12 @@ export interface AriaPositionProps extends PositionProps { * @param target - The target element. */ getTargetRect?: (target: Element) => DOMRect | null | undefined; + /** + * Places the overlay and decides when to update its position. + * + * @default DefaultOverlayPositioner + */ + positioner?: OverlayPositioner; } export interface PositionAria { @@ -166,12 +171,28 @@ export interface PositionAria { updatePosition(): void; } -interface ScrollAnchor { - type: 'top' | 'bottom'; - offset: number; +let visualViewport = typeof document !== 'undefined' ? window.visualViewport : null; + +export interface SubscribeOpts { + targetNode: Element; + overlayNode: Element; + scrollNode: Element; + boundaryElement: Element; } -let visualViewport = typeof document !== 'undefined' ? window.visualViewport : null; +/** + * Places an overlay relative to its target and tells `useOverlayPosition` when to update it. + */ +export interface OverlayPositioner { + /** The CSS position the overlay is rendered with once placed. */ + readonly position: 'absolute' | 'fixed'; + /** Observes layout changes that require an update. Returns a function that stops observing. */ + subscribe(opts: SubscribeOpts, updatePosition: () => void): () => void; + /** Places the overlay. Returns `null` if it cannot place the overlay. */ + update(opts: PositionOpts): PositionResult | null; +} + +const DEFAULT_POSITIONER = new DefaultOverlayPositioner(); /** * Handles positioning overlays like popovers and menus relative to a trigger @@ -196,7 +217,8 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { onClose, maxHeight, arrowBoundaryOffset = 0, - getTargetRect + getTargetRect, + positioner = DEFAULT_POSITIONER } = props; let [position, setPosition] = useState(null); @@ -220,7 +242,8 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { direction, maxHeight, arrowBoundaryOffset, - arrowSize + arrowSize, + positioner ]; // Note, the position freezing breaks if body sizes itself dynamicly with the visual viewport but that might @@ -248,36 +271,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { 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 - // changes, the focused element appears to stay in the same position. - let anchor: ScrollAnchor | null = null; - if (scrollRef.current && isFocusWithin(scrollRef.current)) { - let anchorRect = getActiveElement()?.getBoundingClientRect(); - let scrollRect = scrollRef.current.getBoundingClientRect(); - // Anchor from the top if the offset is in the top half of the scrollable element, - // otherwise anchor from the bottom. - anchor = { - type: 'top', - offset: (anchorRect?.top ?? 0) - scrollRect.top - }; - if (anchor.offset > scrollRect.height / 2) { - anchor.type = 'bottom'; - anchor.offset = (anchorRect?.bottom ?? 0) - scrollRect.bottom; - } - } - - // Always reset the overlay's previous max height if not defined by the user so that we can compensate for - // RAC collections populating after a second render and properly set a correct max height + positioning when it populates. - let overlay = overlayRef.current as HTMLElement; - if (!maxHeight && overlayRef.current) { - overlay.style.top = '0px'; - overlay.style.bottom = ''; - overlay.style.maxHeight = (window.visualViewport?.height ?? window.innerHeight) + 'px'; - } - - let position = calculatePosition({ + let position = positioner.update({ placement: translateRTL(placement, direction), overlayNode: overlayRef.current, targetNode: targetRef.current, @@ -293,31 +287,10 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { targetRect: getTargetRect?.(targetRef.current) }); - if (!position.position) { + if (!position) { return; } - // Modify overlay styles directly so positioning happens immediately without the need of a second render - // This is so we don't have to delay autoFocus scrolling or delay applying preventScroll for popovers - overlay.style.top = ''; - overlay.style.bottom = ''; - overlay.style.left = ''; - overlay.style.right = ''; - - Object.keys(position.position).forEach( - key => (overlay.style[key] = position.position![key] + 'px') - ); - overlay.style.maxHeight = position.maxHeight != null ? position.maxHeight + 'px' : ''; - - // Restore scroll position relative to anchor element. - let activeElement = getActiveElement(); - if (anchor && activeElement && scrollRef.current) { - let anchorRect = activeElement.getBoundingClientRect(); - let scrollRect = scrollRef.current.getBoundingClientRect(); - let newOffset = anchorRect[anchor.type] - scrollRect[anchor.type]; - scrollRef.current.scrollTop += newOffset - anchor.offset; - } - // Trigger a set state for a second render anyway for arrow positioning setPosition(position); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -329,20 +302,23 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // oxlint-disable-next-line react/react-compiler, react-hooks/exhaustive-deps useLayoutEffect(updatePosition, deps); - // Update position on window resize - useResize(updatePosition); - - // Update position when the overlay changes size (might need to flip). - useResizeObserver({ - ref: overlayRef, - onResize: updatePosition - }); + // Update position when the positioner observes a layout change, e.g. on window resize. + useLayoutEffect(() => { + if (!isOpen || !overlayRef.current || !targetRef.current || !boundaryElement) { + return; + } - // Update position when the target changes size (might need to flip). - useResizeObserver({ - ref: targetRef, - onResize: updatePosition - }); + return positioner.subscribe( + { + targetNode: targetRef.current, + overlayNode: overlayRef.current, + scrollNode: scrollRef.current || overlayRef.current, + boundaryElement + }, + updatePosition + ); + // oxlint-disable-next-line react/react-compiler + }, [positioner, isOpen, overlayRef, targetRef, scrollRef, boundaryElement, updatePosition]); // Reposition the overlay and do not close on scroll while the visual viewport is resizing. // This will ensure that overlays adjust their positioning when the iOS virtual keyboard appears. @@ -400,7 +376,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { return { overlayProps: { style: { - position: position ? 'absolute' : 'fixed', + position: position ? positioner.position : 'fixed', top: !position ? 0 : undefined, left: !position ? 0 : undefined, zIndex: 100000, // should match the z-index in ModalTrigger @@ -422,15 +398,6 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { }; } -function useResize(onResize) { - useLayoutEffect(() => { - window.addEventListener('resize', onResize, false); - return () => { - window.removeEventListener('resize', onResize, false); - }; - }, [onResize]); -} - function translateRTL(position, direction) { if (direction === 'rtl') { return position.replace('start', 'right').replace('end', 'left');