Skip to content

Commit 0406af8

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 fe00d39 commit 0406af8

File tree

3 files changed

+99
-5
lines changed

3 files changed

+99
-5
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';
@@ -1090,6 +1091,69 @@ describe('MatTooltip', () => {
10901091
});
10911092
});
10921093

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

10951159
@Component({

src/material/tooltip/tooltip.ts

Lines changed: 32 additions & 3 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

@@ -235,7 +236,13 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
235236
}
236237

237238
/** Manually-bound passive event listeners. */
238-
private _passiveListeners = new Map<string, EventListenerOrEventListenerObject>();
239+
private _passiveListeners = new Map<string, (...args: any[]) => void>();
240+
241+
/**
242+
* Reference to the current document.
243+
* @breaking-change 11.0.0 Remove `| null` typing for `document`.
244+
*/
245+
private _document: Document | null;
239246

240247
/** Timer started at the last `touchstart` event. */
241248
private _touchstartTimeout: number;
@@ -255,7 +262,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
255262
@Inject(MAT_TOOLTIP_SCROLL_STRATEGY) scrollStrategy: any,
256263
@Optional() private _dir: Directionality,
257264
@Optional() @Inject(MAT_TOOLTIP_DEFAULT_OPTIONS)
258-
private _defaultOptions: MatTooltipDefaultOptions) {
265+
private _defaultOptions: MatTooltipDefaultOptions,
266+
267+
/** @breaking-change 11.0.0 _document argument to become required. */
268+
@Inject(DOCUMENT) _document: any) {
259269

260270
this._scrollStrategy = scrollStrategy;
261271

@@ -548,7 +558,8 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
548558
if (!this._platform.IOS && !this._platform.ANDROID) {
549559
this._passiveListeners
550560
.set('mouseenter', () => this.show())
551-
.set('mouseleave', () => this.hide());
561+
.set('mouseleave', () => this.hide())
562+
.set('wheel', this._wheelListener);
552563
} else if (this.touchGestures !== 'off') {
553564
this._disableNativeGesturesIfNecessary();
554565
const touchendListener = () => {
@@ -572,6 +583,24 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
572583
});
573584
}
574585

586+
/** Listener for the `wheel` event on the element. */
587+
private _wheelListener = (event: WheelEvent) => {
588+
if (this._isTooltipVisible()) {
589+
// @breaking-change 11.0.0 Remove `|| document` once the document is a required param.
590+
const doc = this._document || document;
591+
const elementUnderPointer = doc.elementFromPoint(event.clientX, event.clientY);
592+
const element = this._elementRef.nativeElement;
593+
594+
// On non-touch devices we depend on the `mouseleave` event to close the tooltip, but it
595+
// won't fire if the user scrolls away using the wheel without moving their cursor. We
596+
// work around it by finding the element under the user's cursor and closing the tooltip
597+
// if it's not the trigger.
598+
if (elementUnderPointer !== element && !element.contains(elementUnderPointer)) {
599+
this.hide();
600+
}
601+
}
602+
}
603+
575604
/** Disables the native browser gestures, based on how the tooltip has been configured. */
576605
private _disableNativeGesturesIfNecessary() {
577606
const element = this._elementRef.nativeElement;

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)