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'});