Skip to content
Open
41 changes: 41 additions & 0 deletions packages/react-aria-components/test/Tooltip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<div style={{overflow: 'scroll', height: '200px'}} data-testid="scroll-container">
<TestTooltip />
</div>
);

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 (
Expand Down
7 changes: 6 additions & 1 deletion packages/react-aria/src/overlays/useCloseOnScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ||
Expand Down
30 changes: 30 additions & 0 deletions packages/react-aria/src/utils/scrollIntoView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,28 @@
import {getScrollParents} from './getScrollParents';
import {isIOS, isWebKit} from '../utils/platform';

let recentlyScrolledElements = new WeakMap<Node, number>();
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;
Expand Down Expand Up @@ -41,6 +63,8 @@ export function scrollIntoView(
return;
}

markScrolledIntoView(scrollView);

let y = scrollView.scrollTop;
let x = scrollView.scrollLeft;

Expand Down Expand Up @@ -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'});
Expand Down