Skip to content

Commit 49927ae

Browse files
authored
fix(material/tooltip): not closing when scrolling away using mouse wheel (#19735)
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 930bc7e commit 49927ae

File tree

3 files changed

+119
-14
lines changed

3 files changed

+119
-14
lines changed

src/material/tooltip/tooltip.spec.ts

+83-10
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';
@@ -48,13 +49,10 @@ describe('MatTooltip', () => {
4849
let overlayContainer: OverlayContainer;
4950
let overlayContainerElement: HTMLElement;
5051
let dir: {value: Direction};
51-
let platform: {IOS: boolean, isBrowser: boolean, ANDROID: boolean};
52+
let platform: Platform;
5253
let focusMonitor: FocusMonitor;
5354

5455
beforeEach(waitForAsync(() => {
55-
// Set the default Platform override that can be updated before component creation.
56-
platform = {IOS: false, isBrowser: true, ANDROID: false};
57-
5856
TestBed.configureTestingModule({
5957
imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule],
6058
declarations: [
@@ -67,7 +65,6 @@ describe('MatTooltip', () => {
6765
DataBoundAriaLabelTooltip,
6866
],
6967
providers: [
70-
{provide: Platform, useFactory: () => platform},
7168
{provide: Directionality, useFactory: () => {
7269
return dir = {value: 'ltr'};
7370
}}
@@ -76,11 +73,13 @@ describe('MatTooltip', () => {
7673

7774
TestBed.compileComponents();
7875

79-
inject([OverlayContainer, FocusMonitor], (oc: OverlayContainer, fm: FocusMonitor) => {
80-
overlayContainer = oc;
81-
overlayContainerElement = oc.getContainerElement();
82-
focusMonitor = fm;
83-
})();
76+
inject([OverlayContainer, FocusMonitor, Platform],
77+
(oc: OverlayContainer, fm: FocusMonitor, pl: Platform) => {
78+
overlayContainer = oc;
79+
overlayContainerElement = oc.getContainerElement();
80+
focusMonitor = fm;
81+
platform = pl;
82+
})();
8483
}));
8584

8685
afterEach(inject([OverlayContainer], (currentOverlayContainer: OverlayContainer) => {
@@ -886,6 +885,11 @@ describe('MatTooltip', () => {
886885
}));
887886

888887
it('should have rendered the tooltip text on init', fakeAsync(() => {
888+
// We don't bind mouse events on mobile devices.
889+
if (platform.IOS || platform.ANDROID) {
890+
return;
891+
}
892+
889893
dispatchFakeEvent(buttonElement, 'mouseenter');
890894
fixture.detectChanges();
891895
tick(0);
@@ -1089,6 +1093,75 @@ describe('MatTooltip', () => {
10891093
});
10901094
});
10911095

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

10941167
@Component({

src/material/tooltip/tooltip.ts

+33-2
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

@@ -245,6 +246,12 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
245246
private readonly _passiveListeners:
246247
(readonly [string, EventListenerOrEventListenerObject])[] = [];
247248

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

@@ -263,7 +270,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
263270
@Inject(MAT_TOOLTIP_SCROLL_STRATEGY) scrollStrategy: any,
264271
@Optional() private _dir: Directionality,
265272
@Optional() @Inject(MAT_TOOLTIP_DEFAULT_OPTIONS)
266-
private _defaultOptions: MatTooltipDefaultOptions) {
273+
private _defaultOptions: MatTooltipDefaultOptions,
274+
275+
/** @breaking-change 11.0.0 _document argument to become required. */
276+
@Inject(DOCUMENT) _document: any) {
267277

268278
this._scrollStrategy = scrollStrategy;
269279

@@ -590,7 +600,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
590600

591601
const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
592602
if (this._platformSupportsMouseEvents()) {
593-
exitListeners.push(['mouseleave', () => this.hide()]);
603+
exitListeners.push(
604+
['mouseleave', () => this.hide()],
605+
['wheel', event => this._wheelListener(event as WheelEvent)]
606+
);
594607
} else if (this.touchGestures !== 'off') {
595608
this._disableNativeGesturesIfNecessary();
596609
const touchendListener = () => {
@@ -619,6 +632,24 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
619632
return !this._platform.IOS && !this._platform.ANDROID;
620633
}
621634

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

tools/public_api_guard/material/tooltip.d.ts

+3-2
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)