From 51da7c0d36dba7d170d3b50901b917a81688ec89 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 20 Jul 2026 15:53:07 +0530 Subject: [PATCH 1/5] keep tooltip open during keyboard focus restoration --- .../react-aria-components/test/Tooltip.test.js | 17 +++++++++++++++++ .../src/overlays/useCloseOnScroll.ts | 5 +++++ .../react-aria/src/utils/scrollIntoView.ts | 16 ++++++++++++++++ packages/react-aria/test/grid/useGrid.test.js | 18 ++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/packages/react-aria-components/test/Tooltip.test.js b/packages/react-aria-components/test/Tooltip.test.js index 557d43ad43a..43dd5e4f8d1 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 {scrollIntoViewport} 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,22 @@ 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} = renderTooltip(); + + await user.tab(); + let tooltip = getByRole('tooltip'); + expect(tooltip).toBeVisible(); + + scrollIntoViewport(document.body); + fireEvent.scroll(document.body); + expect(tooltip).toBeVisible(); + + act(() => jest.advanceTimersByTime(100)); + 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 0d7e7698876..fc216de3102 100644 --- a/packages/react-aria/src/overlays/useCloseOnScroll.ts +++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts @@ -13,6 +13,7 @@ import {getEventTarget, 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, @@ -37,6 +38,10 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { } let onScroll = (e: Event) => { + if (wasScrolledIntoView()) { + return; + } + // Ignore if scrolling an scrollable region outside the trigger's tree. let target = getEventTarget(e); // window is not a Node and doesn't have contain, but window contains everything diff --git a/packages/react-aria/src/utils/scrollIntoView.ts b/packages/react-aria/src/utils/scrollIntoView.ts index ff975f6c7d0..779a66003f9 100644 --- a/packages/react-aria/src/utils/scrollIntoView.ts +++ b/packages/react-aria/src/utils/scrollIntoView.ts @@ -13,6 +13,19 @@ import {getScrollParents} from './getScrollParents'; import {isIOS} from './platform'; +let lastProgrammaticScrollTime = 0; +function markScrolledIntoView(): void { + lastProgrammaticScrollTime = Date.now(); +} + +/** + * Scroll events don't say what caused them, so useCloseOnScroll uses this to ignore ones it + * triggered itself. + */ +export function wasScrolledIntoView(): boolean { + return Date.now() - lastProgrammaticScrollTime < 100; +} + interface ScrollIntoViewOpts { /** The position to align items along the block axis in. */ block?: ScrollLogicalPosition; @@ -41,6 +54,8 @@ export function scrollIntoView( return; } + markScrolledIntoView(); + let y = scrollView.scrollTop; let x = scrollView.scrollLeft; @@ -145,6 +160,7 @@ export function scrollIntoViewport( ): void { let {containingElement} = opts; if (targetElement && targetElement.isConnected) { + markScrolledIntoView(); let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; if (!isScrollPrevented) { diff --git a/packages/react-aria/test/grid/useGrid.test.js b/packages/react-aria/test/grid/useGrid.test.js index 20b1915ef35..c72b2a2d63e 100644 --- a/packages/react-aria/test/grid/useGrid.test.js +++ b/packages/react-aria/test/grid/useGrid.test.js @@ -157,4 +157,22 @@ describe('useGrid', () => { await user.keyboard('[ArrowLeft]'); expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[0]); }); + + it('should restore focus to the child that was last focused within a cell, not the first child', async () => { + let tree = renderGrid({gridFocusMode: 'cell', cellFocusMode: 'child'}); + let switches = tree.getAllByRole('switch'); + let cells = tree.getAllByRole('gridcell'); + + await user.tab(); + expect(document.activeElement).toBe(switches[0]); + + await user.keyboard('[ArrowRight]'); + expect(document.activeElement).toBe(switches[1]); + + act(() => { + cells[0].focus(); + }); + + expect(document.activeElement).toBe(switches[1]); + }); }); From a3674472156a1a6ea46e9bee79b08f8383452941 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Fri, 24 Jul 2026 11:29:56 +0530 Subject: [PATCH 2/5] fix the lint error. --- packages/react-aria/src/overlays/useCloseOnScroll.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-aria/src/overlays/useCloseOnScroll.ts b/packages/react-aria/src/overlays/useCloseOnScroll.ts index fc216de3102..3ac8a39c844 100644 --- a/packages/react-aria/src/overlays/useCloseOnScroll.ts +++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts @@ -41,7 +41,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { if (wasScrolledIntoView()) { return; } - // Ignore if scrolling an scrollable region outside the trigger's tree. let target = getEventTarget(e); // window is not a Node and doesn't have contain, but window contains everything From 18572a5790082924bb09c1a01ebc76b3d68f4d69 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Fri, 24 Jul 2026 19:24:36 +0530 Subject: [PATCH 3/5] fixed the tooltip closes in the table issue. --- .../test/Tooltip.test.js | 15 ++++++---- .../src/overlays/useCloseOnScroll.ts | 5 ++-- .../react-aria/src/utils/scrollIntoView.ts | 30 ++++++++++++++----- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/react-aria-components/test/Tooltip.test.js b/packages/react-aria-components/test/Tooltip.test.js index 43dd5e4f8d1..71a2ecde8d9 100644 --- a/packages/react-aria-components/test/Tooltip.test.js +++ b/packages/react-aria-components/test/Tooltip.test.js @@ -16,7 +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 {scrollIntoViewport} from '@react-aria/utils'; +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'; @@ -208,18 +208,23 @@ describe('Tooltip', () => { }); it('should not hide tooltip on scroll caused by scrollIntoView/scrollIntoViewport, but should hide on a later unrelated scroll', async () => { - let {getByRole} = renderTooltip(); + let {getByRole, getByTestId} = render( +
+ +
+ ); + let scrollContainer = getByTestId('scroll-container'); await user.tab(); let tooltip = getByRole('tooltip'); expect(tooltip).toBeVisible(); - scrollIntoViewport(document.body); - fireEvent.scroll(document.body); + scrollIntoView(scrollContainer, getByRole('button')); + fireEvent.scroll(scrollContainer); expect(tooltip).toBeVisible(); act(() => jest.advanceTimersByTime(100)); - fireEvent.scroll(document.body); + fireEvent.scroll(scrollContainer); expect(tooltip).not.toBeVisible(); }); diff --git a/packages/react-aria/src/overlays/useCloseOnScroll.ts b/packages/react-aria/src/overlays/useCloseOnScroll.ts index 3ac8a39c844..773214d4fa6 100644 --- a/packages/react-aria/src/overlays/useCloseOnScroll.ts +++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts @@ -38,11 +38,12 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { } let onScroll = (e: Event) => { - if (wasScrolledIntoView()) { + let target = getEventTarget(e); + if (wasScrolledIntoView(target)) { return; } + // Ignore if scrolling an scrollable region outside the trigger's tree. - let target = getEventTarget(e); // 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 779a66003f9..e33a25210c6 100644 --- a/packages/react-aria/src/utils/scrollIntoView.ts +++ b/packages/react-aria/src/utils/scrollIntoView.ts @@ -13,17 +13,26 @@ import {getScrollParents} from './getScrollParents'; import {isIOS} from './platform'; -let lastProgrammaticScrollTime = 0; -function markScrolledIntoView(): void { - lastProgrammaticScrollTime = Date.now(); +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. + * triggered itself, scoped to the specific element that was scrolled. */ -export function wasScrolledIntoView(): boolean { - return Date.now() - lastProgrammaticScrollTime < 100; +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 { @@ -54,7 +63,7 @@ export function scrollIntoView( return; } - markScrolledIntoView(); + markScrolledIntoView(scrollView); let y = scrollView.scrollTop; let x = scrollView.scrollLeft; @@ -160,12 +169,17 @@ export function scrollIntoViewport( ): void { let {containingElement} = opts; if (targetElement && targetElement.isConnected) { - markScrolledIntoView(); let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; 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'}); From eca067788a29f7d5a1042eec7965596a1713b02e Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 27 Jul 2026 11:12:14 +0530 Subject: [PATCH 4/5] fixed the CI test fail issue. --- packages/react-aria/test/grid/useGrid.test.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/react-aria/test/grid/useGrid.test.js b/packages/react-aria/test/grid/useGrid.test.js index c72b2a2d63e..20b1915ef35 100644 --- a/packages/react-aria/test/grid/useGrid.test.js +++ b/packages/react-aria/test/grid/useGrid.test.js @@ -157,22 +157,4 @@ describe('useGrid', () => { await user.keyboard('[ArrowLeft]'); expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[0]); }); - - it('should restore focus to the child that was last focused within a cell, not the first child', async () => { - let tree = renderGrid({gridFocusMode: 'cell', cellFocusMode: 'child'}); - let switches = tree.getAllByRole('switch'); - let cells = tree.getAllByRole('gridcell'); - - await user.tab(); - expect(document.activeElement).toBe(switches[0]); - - await user.keyboard('[ArrowRight]'); - expect(document.activeElement).toBe(switches[1]); - - act(() => { - cells[0].focus(); - }); - - expect(document.activeElement).toBe(switches[1]); - }); }); From 89f1db98ee855a7deabb98e3564658a797ce02f0 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Tue, 28 Jul 2026 12:58:08 +0530 Subject: [PATCH 5/5] Added test case for tooltip. --- .../test/Tooltip.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/react-aria-components/test/Tooltip.test.js b/packages/react-aria-components/test/Tooltip.test.js index 71a2ecde8d9..2bdbda6eacd 100644 --- a/packages/react-aria-components/test/Tooltip.test.js +++ b/packages/react-aria-components/test/Tooltip.test.js @@ -228,6 +228,25 @@ describe('Tooltip', () => { 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 (