From c531a1acad8344ed94d70e5e13930bc737765588 Mon Sep 17 00:00:00 2001 From: pandikapinata Date: Sat, 22 Aug 2026 22:35:36 +0800 Subject: [PATCH] fix(material/tooltip): prevent tooltip from showing when hovering unrelated elements --- src/material/tooltip/tooltip.spec.ts | 62 ++++++++++++++++++++++++++++ src/material/tooltip/tooltip.ts | 28 +++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/material/tooltip/tooltip.spec.ts b/src/material/tooltip/tooltip.spec.ts index 14d35e112833..d99a42d10e9a 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -1042,6 +1042,68 @@ describe('MatTooltip', () => { expect(tooltipDirective._isTooltipVisible()).toBe(false); }); + it('should not treat a nested popover overlay as part of the trigger', () => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + // Regression test for #32747: a trigger can have a popover-based overlay nested inside + // it (e.g. an open mat-select panel). Test-dispatched events aren't trusted, so they + // can't exercise the `mouseenter` guard end-to-end; verify the check it relies on. + const nestedPopover = document.createElement('div'); + nestedPopover.setAttribute('popover', 'manual'); + buttonElement.appendChild(nestedPopover); + + const isPointerOverTrigger = (tooltipDirective as any)._isPointerOverTrigger.bind( + tooltipDirective, + ); + const elementFromPointSpy = spyOn(document, 'elementFromPoint'); + + elementFromPointSpy.and.returnValue(buttonElement); + expect(isPointerOverTrigger(createMouseEvent('mouseenter'))) + .withContext('hovering the trigger itself should count as over the trigger') + .toBe(true); + + elementFromPointSpy.and.returnValue(nestedPopover); + expect(isPointerOverTrigger(createMouseEvent('mouseenter'))) + .withContext( + 'hovering inside a nested popover overlay should not count as over the trigger', + ) + .toBe(false); + + nestedPopover.remove(); + }); + + it('should treat a nested element sticking out of the trigger bounds as part of it', () => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + // Some trigger elements have children that intentionally render outside their own + // bounds, e.g. the `.mat-mdc-button-touch-target` span on buttons, which is positioned + // to extend past the visible button to provide a larger touch target. Those shouldn't be + // mistaken for a nested overlay (see the previous test). + const stickingOutChild = document.createElement('span'); + stickingOutChild.style.position = 'absolute'; + stickingOutChild.style.inset = '-20px'; + buttonElement.appendChild(stickingOutChild); + + const isPointerOverTrigger = (tooltipDirective as any)._isPointerOverTrigger.bind( + tooltipDirective, + ); + spyOn(document, 'elementFromPoint').and.returnValue(stickingOutChild); + + expect(isPointerOverTrigger(createMouseEvent('mouseenter'))) + .withContext( + 'a point over a child sticking out of the trigger should still count as over the trigger', + ) + .toBe(true); + + stickingOutChild.remove(); + }); + it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', async () => { // We don't bind mouse events on mobile devices. if (platform.IOS || platform.ANDROID) { diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index e6d9a41eb346..215b4f37ef6e 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -786,6 +786,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit { // first tap from firing its click event or can cause the tooltip to open for clicks. if (!this._isTouchPlatform()) { this._addListener('mouseenter', (event: MouseEvent) => { + // Untrusted (test-dispatched) events skip the check. See `_isPointerOverTrigger`. + if (event.isTrusted && !this._isPointerOverTrigger(event)) { + return; + } this._setupPointerExitEventsIfNeeded(); let point = undefined; if (event.x !== undefined && event.y !== undefined) { @@ -814,6 +818,30 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } } + /** + * Checks whether the pointer is over the trigger's own surface, as opposed to a popover-based + * overlay nested inside it (e.g. an open `mat-select` panel). Such an overlay's full-viewport + * backdrop ends up as a DOM descendant of the trigger, and `mouseenter` fires on ancestors of + * whatever the pointer lands on, so without this check, hovering anywhere on the page would + * count as hovering the trigger while such an overlay is open. + */ + private _isPointerOverTrigger(event: MouseEvent): boolean { + const nativeElement = this._elementRef.nativeElement; + const target = this._document.elementFromPoint(event.clientX, event.clientY); + + if (!target || !nativeElement.contains(target)) { + return false; + } + + // Ignore points inside a popover-based overlay nested in the trigger (e.g. an open + // `mat-select` panel), that's not part of the trigger's own surface, even though it's + // technically a DOM descendant of it. + const nestedPopover = target.closest('[popover]'); + return ( + !nestedPopover || nestedPopover === nativeElement || !nativeElement.contains(nestedPopover) + ); + } + private _setupPointerExitEventsIfNeeded() { if (this._pointerExitEventsInitialized) { return;