From fea23b80f7903da72b56f888e7a88c3906e3d4a1 Mon Sep 17 00:00:00 2001 From: stop1love1 Date: Mon, 7 Sep 2026 14:05:33 +0700 Subject: [PATCH 1/2] [Tooltip] Close when a scroll moves the trigger away from the cursor Scrolling moves the trigger out from under a motionless cursor without the browser firing any pointer event, so the tooltip stayed open with nothing hovered. Remember where the cursor was when it entered the trigger, and while the tooltip is open, close it on a scroll that leaves the cursor outside both the trigger and the tooltip. Tooltips opened by focus or by touch have no cursor to leave behind and are left untouched. Closes #44548 --- packages/mui-material/src/Tooltip/Tooltip.js | 59 ++++++++ .../mui-material/src/Tooltip/Tooltip.test.js | 129 ++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/packages/mui-material/src/Tooltip/Tooltip.js b/packages/mui-material/src/Tooltip/Tooltip.js index 40fcb9d02f9986..9db5cac2747006 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.js +++ b/packages/mui-material/src/Tooltip/Tooltip.js @@ -219,6 +219,21 @@ export function testReset() { hystersisTimer.clear(); } +function isCursorOver(element, cursorPosition) { + if (!element) { + return false; + } + + const rect = element.getBoundingClientRect(); + + return ( + cursorPosition.x >= rect.left && + cursorPosition.x <= rect.right && + cursorPosition.y >= rect.top && + cursorPosition.y <= rect.bottom + ); +} + function composeEventHandler(handler, eventHandler) { return (event, ...params) => { if (eventHandler) { @@ -265,6 +280,10 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { const [arrowRef, setArrowRef] = React.useState(null); const ignoreNonTouchEvents = React.useRef(false); const openedByDisabledTriggerRef = React.useRef(false); + const popperNodeRef = React.useRef(null); + // Where the cursor was when it last entered the trigger or the tooltip. + // `null` when the tooltip was not opened by a cursor, i.e. by focus or by touch. + const cursorPositionRef = React.useRef(null); const disableInteractive = disableInteractiveProp || followCursor; @@ -336,6 +355,9 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { return; } + cursorPositionRef.current = + event.type === 'mouseover' ? { x: event.clientX, y: event.clientY } : null; + // Remove the title ahead of time. // We don't want to wait for the next render commit. // We would risk displaying two tooltips at the same time (native + this one). @@ -490,6 +512,42 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { }; }, [handleClose, open]); + // Scrolling moves the trigger out from under a motionless cursor without the browser + // firing any pointer event, so the tooltip would stay open with nothing hovered. + // https://github.com/mui/material-ui/issues/44548 + const handleScroll = useEventCallback((nativeEvent) => { + const cursorPosition = cursorPositionRef.current; + + // A tooltip opened by focus stays open as long as the trigger is focused, and a touch + // interaction leaves no cursor behind. Neither is affected by a scroll. + if (!cursorPosition) { + return; + } + + if ( + isCursorOver(childNode, cursorPosition) || + (!disableInteractive && isCursorOver(popperNodeRef.current, cursorPosition)) + ) { + return; + } + + handleMouseLeave(nativeEvent); + }); + + React.useEffect(() => { + if (!open) { + return undefined; + } + + // `scroll` does not bubble, the listener has to run in the capture phase to catch + // scrolls happening in any container between the document and the trigger. + document.addEventListener('scroll', handleScroll, true); + + return () => { + document.removeEventListener('scroll', handleScroll, true); + }; + }, [handleScroll, open]); + const handleRef = useForkRef(getReactElementRef(children), setChildNode, ref); // There is no point in displaying an empty tooltip. @@ -639,6 +697,7 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) { externalForwardedProps, ownerState, className: classes.popper, + ref: popperNodeRef, }); const [TransitionSlot, transitionSlotProps] = useSlot('transition', { diff --git a/packages/mui-material/src/Tooltip/Tooltip.test.js b/packages/mui-material/src/Tooltip/Tooltip.test.js index 223af6d372bf43..36d2510099dfe1 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.test.js +++ b/packages/mui-material/src/Tooltip/Tooltip.test.js @@ -632,6 +632,135 @@ describe('', () => { }); }); + describe('scroll', () => { + // JSDOM has no layout, the rects the component reads have to be provided by the test. + function setRect(element, { top, left, bottom, right }) { + element.getBoundingClientRect = () => ({ + top, + left, + bottom, + right, + x: left, + y: top, + width: right - left, + height: bottom - top, + toJSON() {}, + }); + } + + function renderTooltip(props) { + const handleClose = spy(); + + render( +
+ + + +
, + ); + + const trigger = screen.getByRole('button'); + setRect(trigger, { top: 0, left: 0, bottom: 20, right: 100 }); + + return { handleClose, trigger }; + } + + it('should close when a scroll moves the trigger away from the cursor', async () => { + const { handleClose, trigger } = renderTooltip(); + + fireEvent.mouseOver(trigger, { clientX: 50, clientY: 10 }); + clock.tick(100); + + expect(screen.getByRole('tooltip')).toBeVisible(); + + // The container scrolls under a motionless cursor: the trigger is no longer below it. + setRect(trigger, { top: -60, left: 0, bottom: -40, right: 100 }); + fireEvent.scroll(screen.getByTestId('scroller')); + // Popper schedules its update in a microtask, flush it before moving on. + await act(async () => { + await Promise.resolve(); + }); + clock.tick(111); + clock.tick(10); + + expect(handleClose.callCount).to.equal(1); + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + + it('should stay open when the cursor is still over the trigger after the scroll', async () => { + const { handleClose, trigger } = renderTooltip(); + + fireEvent.mouseOver(trigger, { clientX: 50, clientY: 10 }); + clock.tick(100); + + // Scrolled by 5px only, the cursor is still within the trigger. + setRect(trigger, { top: -5, left: 0, bottom: 15, right: 100 }); + fireEvent.scroll(document); + // Popper schedules its update in a microtask, flush it before moving on. + await act(async () => { + await Promise.resolve(); + }); + clock.tick(111); + clock.tick(10); + + expect(handleClose.callCount).to.equal(0); + expect(screen.getByRole('tooltip')).toBeVisible(); + }); + + it('should stay open when the cursor is over an interactive tooltip', async () => { + const { handleClose, trigger } = renderTooltip(); + + fireEvent.mouseOver(trigger, { clientX: 50, clientY: 10 }); + clock.tick(100); + + // The cursor moved from the trigger onto the tooltip. + const tooltip = screen.getByRole('tooltip'); + fireEvent.mouseLeave(trigger); + fireEvent.mouseOver(tooltip, { clientX: 50, clientY: 60 }); + setRect(trigger, { top: -40, left: 0, bottom: -20, right: 100 }); + setRect(tooltip, { top: 50, left: 0, bottom: 80, right: 100 }); + fireEvent.scroll(document); + // Popper schedules its update in a microtask, flush it before moving on. + await act(async () => { + await Promise.resolve(); + }); + clock.tick(111); + clock.tick(10); + + expect(handleClose.callCount).to.equal(0); + expect(screen.queryByRole('tooltip')).not.to.equal(null); + }); + + it('should not close a tooltip that was not opened by the cursor', async () => { + const enterTouchDelay = 700; + const { handleClose, trigger } = renderTooltip({ enterTouchDelay }); + + fireEvent.touchStart(trigger); + clock.tick(enterTouchDelay + 100); + + expect(screen.getByRole('tooltip')).toBeVisible(); + + setRect(trigger, { top: -60, left: 0, bottom: -40, right: 100 }); + fireEvent.scroll(document); + // Popper schedules its update in a microtask, flush it before moving on. + await act(async () => { + await Promise.resolve(); + }); + clock.tick(111); + clock.tick(10); + + expect(handleClose.callCount).to.equal(0); + expect(screen.queryByRole('tooltip')).not.to.equal(null); + }); + }); + describe('mount', () => { it('should mount without any issue', () => { render( From d3ed0dec366d6964dab6071ebf5668a45832bf95 Mon Sep 17 00:00:00 2001 From: stop1love1 Date: Mon, 7 Sep 2026 14:17:17 +0700 Subject: [PATCH 2/2] [Tooltip] Make the scroll tests independent of the tooltip layout The tests only stubbed the trigger rect, so whether the cursor counted as being over the tooltip depended on where Popper laid it out, which in turn depends on the rendered size of the tooltip. The tooltip wraps differently on the CI font stack than it does locally, and the popper ended up covering the simulated cursor there. Stub every rect the component reads and keep the whole scenario away from the viewport origin so no real pointer of the test browser can interfere. --- .../mui-material/src/Tooltip/Tooltip.test.js | 88 ++++++++++--------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/packages/mui-material/src/Tooltip/Tooltip.test.js b/packages/mui-material/src/Tooltip/Tooltip.test.js index 36d2510099dfe1..804b8db6dba9ef 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.test.js +++ b/packages/mui-material/src/Tooltip/Tooltip.test.js @@ -633,7 +633,17 @@ describe('', () => { }); describe('scroll', () => { - // JSDOM has no layout, the rects the component reads have to be provided by the test. + // The trigger sits away from the viewport origin so that the tooltip is never rendered + // under the real pointer of the browser running the test, which would fire a genuine + // `mouseover` and overwrite the cursor position the test is simulating. + const TRIGGER_RECT = { top: 100, left: 100, bottom: 120, right: 200 }; + const TOOLTIP_RECT = { top: 130, left: 100, bottom: 160, right: 200 }; + const SCROLLED_AWAY_RECT = { top: 20, left: 100, bottom: 40, right: 200 }; + const CURSOR_ON_TRIGGER = { clientX: 150, clientY: 110 }; + const CURSOR_ON_TOOLTIP = { clientX: 150, clientY: 140 }; + + // Neither JSDOM nor a freshly laid out popper gives the test control over the + // geometry the component reads, so every rect it looks at is provided here. function setRect(element, { top, left, bottom, right }) { element.getBoundingClientRect = () => ({ top, @@ -667,28 +677,34 @@ describe('', () => { ); const trigger = screen.getByRole('button'); - setRect(trigger, { top: 0, left: 0, bottom: 20, right: 100 }); + setRect(trigger, TRIGGER_RECT); return { handleClose, trigger }; } - it('should close when a scroll moves the trigger away from the cursor', async () => { - const { handleClose, trigger } = renderTooltip(); - - fireEvent.mouseOver(trigger, { clientX: 50, clientY: 10 }); - clock.tick(100); - - expect(screen.getByRole('tooltip')).toBeVisible(); - - // The container scrolls under a motionless cursor: the trigger is no longer below it. - setRect(trigger, { top: -60, left: 0, bottom: -40, right: 100 }); - fireEvent.scroll(screen.getByTestId('scroller')); + async function scroll(element) { + fireEvent.scroll(element); // Popper schedules its update in a microtask, flush it before moving on. await act(async () => { await Promise.resolve(); }); clock.tick(111); clock.tick(10); + } + + it('should close when a scroll moves the trigger away from the cursor', async () => { + const { handleClose, trigger } = renderTooltip(); + + fireEvent.mouseOver(trigger, CURSOR_ON_TRIGGER); + clock.tick(100); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toBeVisible(); + setRect(tooltip, TOOLTIP_RECT); + + // The container scrolls under a motionless cursor: nothing is below it anymore. + setRect(trigger, SCROLLED_AWAY_RECT); + await scroll(screen.getByTestId('scroller')); expect(handleClose.callCount).to.equal(1); expect(screen.queryByRole('tooltip')).to.equal(null); @@ -697,42 +713,32 @@ describe('', () => { it('should stay open when the cursor is still over the trigger after the scroll', async () => { const { handleClose, trigger } = renderTooltip(); - fireEvent.mouseOver(trigger, { clientX: 50, clientY: 10 }); + fireEvent.mouseOver(trigger, CURSOR_ON_TRIGGER); clock.tick(100); + setRect(screen.getByRole('tooltip'), TOOLTIP_RECT); // Scrolled by 5px only, the cursor is still within the trigger. - setRect(trigger, { top: -5, left: 0, bottom: 15, right: 100 }); - fireEvent.scroll(document); - // Popper schedules its update in a microtask, flush it before moving on. - await act(async () => { - await Promise.resolve(); - }); - clock.tick(111); - clock.tick(10); + setRect(trigger, { ...TRIGGER_RECT, top: 95, bottom: 115 }); + await scroll(document); expect(handleClose.callCount).to.equal(0); - expect(screen.getByRole('tooltip')).toBeVisible(); + expect(screen.queryByRole('tooltip')).not.to.equal(null); }); it('should stay open when the cursor is over an interactive tooltip', async () => { const { handleClose, trigger } = renderTooltip(); - fireEvent.mouseOver(trigger, { clientX: 50, clientY: 10 }); + fireEvent.mouseOver(trigger, CURSOR_ON_TRIGGER); clock.tick(100); // The cursor moved from the trigger onto the tooltip. const tooltip = screen.getByRole('tooltip'); + setRect(tooltip, TOOLTIP_RECT); fireEvent.mouseLeave(trigger); - fireEvent.mouseOver(tooltip, { clientX: 50, clientY: 60 }); - setRect(trigger, { top: -40, left: 0, bottom: -20, right: 100 }); - setRect(tooltip, { top: 50, left: 0, bottom: 80, right: 100 }); - fireEvent.scroll(document); - // Popper schedules its update in a microtask, flush it before moving on. - await act(async () => { - await Promise.resolve(); - }); - clock.tick(111); - clock.tick(10); + fireEvent.mouseOver(tooltip, CURSOR_ON_TOOLTIP); + + setRect(trigger, SCROLLED_AWAY_RECT); + await scroll(document); expect(handleClose.callCount).to.equal(0); expect(screen.queryByRole('tooltip')).not.to.equal(null); @@ -745,16 +751,12 @@ describe('', () => { fireEvent.touchStart(trigger); clock.tick(enterTouchDelay + 100); - expect(screen.getByRole('tooltip')).toBeVisible(); + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toBeVisible(); + setRect(tooltip, TOOLTIP_RECT); - setRect(trigger, { top: -60, left: 0, bottom: -40, right: 100 }); - fireEvent.scroll(document); - // Popper schedules its update in a microtask, flush it before moving on. - await act(async () => { - await Promise.resolve(); - }); - clock.tick(111); - clock.tick(10); + setRect(trigger, SCROLLED_AWAY_RECT); + await scroll(document); expect(handleClose.callCount).to.equal(0); expect(screen.queryByRole('tooltip')).not.to.equal(null);