Skip to content

Commit 2ee56c9

Browse files
committed
fix(tooltip): not closing when scrolling away using mouse wheel
We depend on the `mouseleave` event to close the tooltip, but it won't fire if the user scrolls away without moving their mouse. These changes add some logic so the tooltip is closed if a `wheel` event resulted in the cursor leaving the trigger. Fixes #18611.
1 parent 294b8ee commit 2ee56c9

File tree

3 files changed

+100
-4
lines changed

3 files changed

+100
-4
lines changed

src/material/tooltip/tooltip.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
dispatchMouseEvent,
3030
createKeyboardEvent,
3131
dispatchEvent,
32+
createFakeEvent,
3233
} from '@angular/cdk/testing/private';
3334
import {ESCAPE} from '@angular/cdk/keycodes';
3435
import {FocusMonitor} from '@angular/cdk/a11y';
@@ -1089,6 +1090,69 @@ describe('MatTooltip', () => {
10891090
});
10901091
});
10911092

1093+
describe('mouse wheel handling', () => {
1094+
beforeEach(() => {
1095+
platform.IOS = platform.ANDROID = false;
1096+
});
1097+
1098+
it('should close when a wheel event causes the cursor to leave the trigger', fakeAsync(() => {
1099+
const fixture = TestBed.createComponent(BasicTooltipDemo);
1100+
fixture.detectChanges();
1101+
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
1102+
1103+
dispatchFakeEvent(button, 'mouseenter');
1104+
fixture.detectChanges();
1105+
tick(500); // Finish the open delay.
1106+
fixture.detectChanges();
1107+
tick(500); // Finish the animation.
1108+
assertTooltipInstance(fixture.componentInstance.tooltip, true);
1109+
1110+
// Simulate the pointer at the bottom/right of the page.
1111+
const wheelEvent = createFakeEvent('wheel');
1112+
Object.defineProperties(wheelEvent, {
1113+
clientX: {get: () => window.innerWidth},
1114+
clientY: {get: () => window.innerHeight}
1115+
});
1116+
1117+
dispatchEvent(button, wheelEvent);
1118+
fixture.detectChanges();
1119+
tick(1500); // Finish the delay.
1120+
fixture.detectChanges();
1121+
tick(500); // Finish the exit animation.
1122+
1123+
assertTooltipInstance(fixture.componentInstance.tooltip, false);
1124+
}));
1125+
1126+
it('should not close if the cursor is over the trigger after a wheel event', fakeAsync(() => {
1127+
const fixture = TestBed.createComponent(BasicTooltipDemo);
1128+
fixture.detectChanges();
1129+
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
1130+
1131+
dispatchFakeEvent(button, 'mouseenter');
1132+
fixture.detectChanges();
1133+
tick(500); // Finish the open delay.
1134+
fixture.detectChanges();
1135+
tick(500); // Finish the animation.
1136+
assertTooltipInstance(fixture.componentInstance.tooltip, true);
1137+
1138+
// Simulate the pointer over the trigger.
1139+
const triggerRect = button.getBoundingClientRect();
1140+
const wheelEvent = createFakeEvent('wheel');
1141+
Object.defineProperties(wheelEvent, {
1142+
clientX: {get: () => triggerRect.left + 1},
1143+
clientY: {get: () => triggerRect.top + 1}
1144+
});
1145+
1146+
dispatchEvent(button, wheelEvent);
1147+
fixture.detectChanges();
1148+
tick(1500); // Finish the delay.
1149+
fixture.detectChanges();
1150+
tick(500); // Finish the exit animation.
1151+
1152+
assertTooltipInstance(fixture.componentInstance.tooltip, true);
1153+
}));
1154+
});
1155+
10921156
});
10931157

10941158
@Component({

src/material/tooltip/tooltip.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
ViewEncapsulation,
4141
AfterViewInit,
4242
} from '@angular/core';
43+
import {DOCUMENT} from '@angular/common';
4344
import {Observable, Subject} from 'rxjs';
4445
import {take, takeUntil} from 'rxjs/operators';
4546

@@ -243,6 +244,12 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
243244
private readonly _passiveListeners:
244245
(readonly [string, EventListenerOrEventListenerObject])[] = [];
245246

247+
/**
248+
* Reference to the current document.
249+
* @breaking-change 11.0.0 Remove `| null` typing for `document`.
250+
*/
251+
private _document: Document | null;
252+
246253
/** Timer started at the last `touchstart` event. */
247254
private _touchstartTimeout: number;
248255

@@ -261,7 +268,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
261268
@Inject(MAT_TOOLTIP_SCROLL_STRATEGY) scrollStrategy: any,
262269
@Optional() private _dir: Directionality,
263270
@Optional() @Inject(MAT_TOOLTIP_DEFAULT_OPTIONS)
264-
private _defaultOptions: MatTooltipDefaultOptions) {
271+
private _defaultOptions: MatTooltipDefaultOptions,
272+
273+
/** @breaking-change 11.0.0 _document argument to become required. */
274+
@Inject(DOCUMENT) _document: any) {
265275

266276
this._scrollStrategy = scrollStrategy;
267277

@@ -588,7 +598,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
588598

589599
const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
590600
if (this._platformSupportsMouseEvents()) {
591-
exitListeners.push(['mouseleave', () => this.hide()]);
601+
exitListeners.push(
602+
['mouseleave', () => this.hide()],
603+
['wheel', event => this._wheelListener(event as WheelEvent)]
604+
);
592605
} else if (this.touchGestures !== 'off') {
593606
this._disableNativeGesturesIfNecessary();
594607
const touchendListener = () => {
@@ -617,6 +630,24 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
617630
return !this._platform.IOS && !this._platform.ANDROID;
618631
}
619632

633+
/** Listener for the `wheel` event on the element. */
634+
private _wheelListener(event: WheelEvent) {
635+
if (this._isTooltipVisible()) {
636+
// @breaking-change 11.0.0 Remove `|| document` once the document is a required param.
637+
const doc = this._document || document;
638+
const elementUnderPointer = doc.elementFromPoint(event.clientX, event.clientY);
639+
const element = this._elementRef.nativeElement;
640+
641+
// On non-touch devices we depend on the `mouseleave` event to close the tooltip, but it
642+
// won't fire if the user scrolls away using the wheel without moving their cursor. We
643+
// work around it by finding the element under the user's cursor and closing the tooltip
644+
// if it's not the trigger.
645+
if (elementUnderPointer !== element && !element.contains(elementUnderPointer)) {
646+
this.hide();
647+
}
648+
}
649+
}
650+
620651
/** Disables the native browser gestures, based on how the tooltip has been configured. */
621652
private _disableNativeGesturesIfNecessary() {
622653
const gestures = this.touchGestures;

tools/public_api_guard/material/tooltip.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export declare class MatTooltip implements OnDestroy, AfterViewInit {
3232
[key: string]: any;
3333
});
3434
touchGestures: TooltipTouchGestures;
35-
constructor(_overlay: Overlay, _elementRef: ElementRef<HTMLElement>, _scrollDispatcher: ScrollDispatcher, _viewContainerRef: ViewContainerRef, _ngZone: NgZone, _platform: Platform, _ariaDescriber: AriaDescriber, _focusMonitor: FocusMonitor, scrollStrategy: any, _dir: Directionality, _defaultOptions: MatTooltipDefaultOptions);
35+
constructor(_overlay: Overlay, _elementRef: ElementRef<HTMLElement>, _scrollDispatcher: ScrollDispatcher, _viewContainerRef: ViewContainerRef, _ngZone: NgZone, _platform: Platform, _ariaDescriber: AriaDescriber, _focusMonitor: FocusMonitor, scrollStrategy: any, _dir: Directionality, _defaultOptions: MatTooltipDefaultOptions,
36+
_document: any);
3637
_getOrigin(): {
3738
main: OriginConnectionPosition;
3839
fallback: OriginConnectionPosition;
@@ -51,7 +52,7 @@ export declare class MatTooltip implements OnDestroy, AfterViewInit {
5152
static ngAcceptInputType_hideDelay: NumberInput;
5253
static ngAcceptInputType_showDelay: NumberInput;
5354
static ɵdir: i0.ɵɵDirectiveDefWithMeta<MatTooltip, "[matTooltip]", ["matTooltip"], { "position": "matTooltipPosition"; "disabled": "matTooltipDisabled"; "showDelay": "matTooltipShowDelay"; "hideDelay": "matTooltipHideDelay"; "touchGestures": "matTooltipTouchGestures"; "message": "matTooltip"; "tooltipClass": "matTooltipClass"; }, {}, never>;
54-
static ɵfac: i0.ɵɵFactoryDef<MatTooltip, [null, null, null, null, null, null, null, null, null, { optional: true; }, { optional: true; }]>;
55+
static ɵfac: i0.ɵɵFactoryDef<MatTooltip, [null, null, null, null, null, null, null, null, null, { optional: true; }, { optional: true; }, null]>;
5556
}
5657

5758
export declare const matTooltipAnimations: {

0 commit comments

Comments
 (0)