Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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