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..804b8db6dba9ef 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.test.js +++ b/packages/mui-material/src/Tooltip/Tooltip.test.js @@ -632,6 +632,137 @@ describe('', () => { }); }); + describe('scroll', () => { + // 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, + 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, TRIGGER_RECT); + + return { handleClose, trigger }; + } + + 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); + }); + + it('should stay open when the cursor is still over the trigger after the scroll', async () => { + const { handleClose, trigger } = renderTooltip(); + + 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, { ...TRIGGER_RECT, top: 95, bottom: 115 }); + await scroll(document); + + expect(handleClose.callCount).to.equal(0); + 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, 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, 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); + }); + + 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); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toBeVisible(); + setRect(tooltip, TOOLTIP_RECT); + + setRect(trigger, SCROLLED_AWAY_RECT); + await scroll(document); + + expect(handleClose.callCount).to.equal(0); + expect(screen.queryByRole('tooltip')).not.to.equal(null); + }); + }); + describe('mount', () => { it('should mount without any issue', () => { render(