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
59 changes: 59 additions & 0 deletions packages/mui-material/src/Tooltip/Tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -639,6 +697,7 @@ const Tooltip = React.forwardRef(function Tooltip(inProps, ref) {
externalForwardedProps,
ownerState,
className: classes.popper,
ref: popperNodeRef,
});

const [TransitionSlot, transitionSlotProps] = useSlot('transition', {
Expand Down
131 changes: 131 additions & 0 deletions packages/mui-material/src/Tooltip/Tooltip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,137 @@ describe('<Tooltip />', () => {
});
});

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(
<div data-testid="scroller">
<Tooltip
title="Hello World"
enterDelay={100}
leaveDelay={111}
onClose={handleClose}
slotProps={{ transition: { timeout: 10 } }}
{...props}
>
<button type="submit">Hello World</button>
</Tooltip>
</div>,
);

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(
Expand Down
Loading