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
62 changes: 62 additions & 0 deletions src/material/tooltip/tooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions src/material/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down