diff --git a/packages/react-aria-components/test/Tooltip.test.js b/packages/react-aria-components/test/Tooltip.test.js
index 557d43ad43a..2bdbda6eacd 100644
--- a/packages/react-aria-components/test/Tooltip.test.js
+++ b/packages/react-aria-components/test/Tooltip.test.js
@@ -16,6 +16,7 @@ import {Focusable} from 'react-aria/Focusable';
import {OverlayArrow} from '../src/OverlayArrow';
import {Pressable} from 'react-aria/Pressable';
import React, {useRef} from 'react';
+import {scrollIntoView} from '@react-aria/utils';
import {Tooltip, TooltipTrigger} from '../src/Tooltip';
import {UNSAFE_PortalProvider} from 'react-aria/PortalProvider';
import userEvent from '@testing-library/user-event';
@@ -206,6 +207,46 @@ describe('Tooltip', () => {
expect(tooltip1).not.toBeVisible();
});
+ it('should not hide tooltip on scroll caused by scrollIntoView/scrollIntoViewport, but should hide on a later unrelated scroll', async () => {
+ let {getByRole, getByTestId} = render(
+
+
+
+ );
+
+ let scrollContainer = getByTestId('scroll-container');
+ await user.tab();
+ let tooltip = getByRole('tooltip');
+ expect(tooltip).toBeVisible();
+
+ scrollIntoView(scrollContainer, getByRole('button'));
+ fireEvent.scroll(scrollContainer);
+ expect(tooltip).toBeVisible();
+
+ act(() => jest.advanceTimersByTime(100));
+ fireEvent.scroll(scrollContainer);
+ expect(tooltip).not.toBeVisible();
+ });
+
+ it('should still hide tooltip on scroll when an unrelated element is scrolled into view elsewhere', async () => {
+ let {getByRole} = renderTooltip();
+
+ await user.tab();
+ let tooltip = getByRole('tooltip');
+ expect(tooltip).toBeVisible();
+
+ // An unrelated part of the page (e.g. a Table doing keyboard navigation) scrolls its own,
+ // unrelated container into view. This should not affect this tooltip at all.
+ let unrelatedContainer = document.createElement('div');
+ let unrelatedChild = document.createElement('div');
+ unrelatedContainer.appendChild(unrelatedChild);
+ document.body.appendChild(unrelatedContainer);
+ scrollIntoView(unrelatedContainer, unrelatedChild);
+
+ fireEvent.scroll(document.body);
+ expect(tooltip).not.toBeVisible();
+ });
+
describe('portalProvider', () => {
function InfoTooltip(props) {
return (
diff --git a/packages/react-aria/src/overlays/useCloseOnScroll.ts b/packages/react-aria/src/overlays/useCloseOnScroll.ts
index 5c50db3b1c4..89156272281 100644
--- a/packages/react-aria/src/overlays/useCloseOnScroll.ts
+++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts
@@ -14,6 +14,7 @@ import {addEvent} from '../utils/domHelpers';
import {getEventTarget, getPropagationTargets, nodeContains} from '../utils/shadowdom/DOMFunctions';
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
+import {wasScrolledIntoView} from '../utils/scrollIntoView';
// This behavior moved from useOverlayTrigger to useOverlayPosition.
// For backward compatibility, where useOverlayTrigger handled hiding the popover on close,
@@ -38,8 +39,12 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
}
let onScroll = (e: Event) => {
- // Ignore if scrolling an scrollable region outside the trigger's tree.
let target = getEventTarget(e);
+ if (wasScrolledIntoView(target)) {
+ return;
+ }
+
+ // Ignore if scrolling an scrollable region outside the trigger's tree.
// window is not a Node and doesn't have contain, but window contains everything
if (
!triggerRef.current ||
diff --git a/packages/react-aria/src/utils/scrollIntoView.ts b/packages/react-aria/src/utils/scrollIntoView.ts
index 86bbfcf5ac8..8f35515ed31 100644
--- a/packages/react-aria/src/utils/scrollIntoView.ts
+++ b/packages/react-aria/src/utils/scrollIntoView.ts
@@ -13,6 +13,28 @@
import {getScrollParents} from './getScrollParents';
import {isIOS, isWebKit} from '../utils/platform';
+let recentlyScrolledElements = new WeakMap();
+function markScrolledIntoView(...elements: (Node | null | undefined)[]): void {
+ let time = Date.now();
+ for (let element of elements) {
+ if (element) {
+ recentlyScrolledElements.set(element, time);
+ }
+ }
+}
+
+/**
+ * Scroll events don't say what caused them, so useCloseOnScroll uses this to ignore ones it
+ * triggered itself, scoped to the specific element that was scrolled.
+ */
+export function wasScrolledIntoView(target: EventTarget | null): boolean {
+ if (!(target instanceof Node)) {
+ return false;
+ }
+ let time = recentlyScrolledElements.get(target);
+ return time != null && Date.now() - time < 100;
+}
+
interface ScrollIntoViewOpts {
/** The position to align items along the block axis in. */
block?: ScrollLogicalPosition;
@@ -41,6 +63,8 @@ export function scrollIntoView(
return;
}
+ markScrolledIntoView(scrollView);
+
let y = scrollView.scrollTop;
let x = scrollView.scrollLeft;
@@ -150,6 +174,12 @@ export function scrollIntoViewport(
if (!isScrollPrevented) {
let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect();
+ // Mark every scrollable ancestor since the native scrollIntoView calls below may scroll any of them.
+ markScrolledIntoView(...getScrollParents(targetElement, true));
+ if (containingElement) {
+ markScrolledIntoView(...getScrollParents(containingElement, true));
+ }
+
// use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus()
// won't cause a scroll if the element is already focused and doesn't behave consistently when an element is partially out of view horizontally vs vertically
targetElement?.scrollIntoView?.({block: 'nearest'});