Skip to content

Commit c531a1a

Browse files
committed
fix(material/tooltip): prevent tooltip from showing when hovering unrelated elements
1 parent cd5d811 commit c531a1a

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

‎src/material/tooltip/tooltip.spec.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,68 @@ describe('MatTooltip', () => {
10421042
expect(tooltipDirective._isTooltipVisible()).toBe(false);
10431043
});
10441044

1045+
it('should not treat a nested popover overlay as part of the trigger', () => {
1046+
// We don't bind mouse events on mobile devices.
1047+
if (platform.IOS || platform.ANDROID) {
1048+
return;
1049+
}
1050+
1051+
// Regression test for #32747: a trigger can have a popover-based overlay nested inside
1052+
// it (e.g. an open mat-select panel). Test-dispatched events aren't trusted, so they
1053+
// can't exercise the `mouseenter` guard end-to-end; verify the check it relies on.
1054+
const nestedPopover = document.createElement('div');
1055+
nestedPopover.setAttribute('popover', 'manual');
1056+
buttonElement.appendChild(nestedPopover);
1057+
1058+
const isPointerOverTrigger = (tooltipDirective as any)._isPointerOverTrigger.bind(
1059+
tooltipDirective,
1060+
);
1061+
const elementFromPointSpy = spyOn(document, 'elementFromPoint');
1062+
1063+
elementFromPointSpy.and.returnValue(buttonElement);
1064+
expect(isPointerOverTrigger(createMouseEvent('mouseenter')))
1065+
.withContext('hovering the trigger itself should count as over the trigger')
1066+
.toBe(true);
1067+
1068+
elementFromPointSpy.and.returnValue(nestedPopover);
1069+
expect(isPointerOverTrigger(createMouseEvent('mouseenter')))
1070+
.withContext(
1071+
'hovering inside a nested popover overlay should not count as over the trigger',
1072+
)
1073+
.toBe(false);
1074+
1075+
nestedPopover.remove();
1076+
});
1077+
1078+
it('should treat a nested element sticking out of the trigger bounds as part of it', () => {
1079+
// We don't bind mouse events on mobile devices.
1080+
if (platform.IOS || platform.ANDROID) {
1081+
return;
1082+
}
1083+
1084+
// Some trigger elements have children that intentionally render outside their own
1085+
// bounds, e.g. the `.mat-mdc-button-touch-target` span on buttons, which is positioned
1086+
// to extend past the visible button to provide a larger touch target. Those shouldn't be
1087+
// mistaken for a nested overlay (see the previous test).
1088+
const stickingOutChild = document.createElement('span');
1089+
stickingOutChild.style.position = 'absolute';
1090+
stickingOutChild.style.inset = '-20px';
1091+
buttonElement.appendChild(stickingOutChild);
1092+
1093+
const isPointerOverTrigger = (tooltipDirective as any)._isPointerOverTrigger.bind(
1094+
tooltipDirective,
1095+
);
1096+
spyOn(document, 'elementFromPoint').and.returnValue(stickingOutChild);
1097+
1098+
expect(isPointerOverTrigger(createMouseEvent('mouseenter')))
1099+
.withContext(
1100+
'a point over a child sticking out of the trigger should still count as over the trigger',
1101+
)
1102+
.toBe(true);
1103+
1104+
stickingOutChild.remove();
1105+
});
1106+
10451107
it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', async () => {
10461108
// We don't bind mouse events on mobile devices.
10471109
if (platform.IOS || platform.ANDROID) {

‎src/material/tooltip/tooltip.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
786786
// first tap from firing its click event or can cause the tooltip to open for clicks.
787787
if (!this._isTouchPlatform()) {
788788
this._addListener('mouseenter', (event: MouseEvent) => {
789+
// Untrusted (test-dispatched) events skip the check. See `_isPointerOverTrigger`.
790+
if (event.isTrusted && !this._isPointerOverTrigger(event)) {
791+
return;
792+
}
789793
this._setupPointerExitEventsIfNeeded();
790794
let point = undefined;
791795
if (event.x !== undefined && event.y !== undefined) {
@@ -814,6 +818,30 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
814818
}
815819
}
816820

821+
/**
822+
* Checks whether the pointer is over the trigger's own surface, as opposed to a popover-based
823+
* overlay nested inside it (e.g. an open `mat-select` panel). Such an overlay's full-viewport
824+
* backdrop ends up as a DOM descendant of the trigger, and `mouseenter` fires on ancestors of
825+
* whatever the pointer lands on, so without this check, hovering anywhere on the page would
826+
* count as hovering the trigger while such an overlay is open.
827+
*/
828+
private _isPointerOverTrigger(event: MouseEvent): boolean {
829+
const nativeElement = this._elementRef.nativeElement;
830+
const target = this._document.elementFromPoint(event.clientX, event.clientY);
831+
832+
if (!target || !nativeElement.contains(target)) {
833+
return false;
834+
}
835+
836+
// Ignore points inside a popover-based overlay nested in the trigger (e.g. an open
837+
// `mat-select` panel), that's not part of the trigger's own surface, even though it's
838+
// technically a DOM descendant of it.
839+
const nestedPopover = target.closest('[popover]');
840+
return (
841+
!nestedPopover || nestedPopover === nativeElement || !nativeElement.contains(nestedPopover)
842+
);
843+
}
844+
817845
private _setupPointerExitEventsIfNeeded() {
818846
if (this._pointerExitEventsInitialized) {
819847
return;

0 commit comments

Comments
 (0)