Skip to content

Commit 04b7523

Browse files
authored
perf(tooltip): Hook up to pointer leave events when pointer enter events fire (#19777)
Only adds listeners for leave events _after_ an enter event occurs, rather than eagerly.
1 parent 75e0612 commit 04b7523

File tree

2 files changed

+60
-27
lines changed

2 files changed

+60
-27
lines changed

src/cdk/a11y/aria-describer/aria-describer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export class AriaDescriber implements OnDestroy {
8585

8686
/** Removes the host element's aria-describedby reference to the message element. */
8787
removeDescription(hostElement: Element, message: string|HTMLElement) {
88-
if (!this._isElementNode(hostElement)) {
88+
if (!message || !this._isElementNode(hostElement)) {
8989
return;
9090
}
9191

src/material/tooltip/tooltip.ts

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
145145
private _tooltipClass: string|string[]|Set<string>|{[key: string]: any};
146146
private _scrollStrategy: () => ScrollStrategy;
147147
private _viewInitialized = false;
148+
private _pointerExitEventsInitialized = false;
148149

149150
/** Allows the user to define the position of the tooltip relative to the parent element */
150151
@Input('matTooltipPosition')
@@ -175,7 +176,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
175176
if (this._disabled) {
176177
this.hide(0);
177178
} else {
178-
this._setupPointerEvents();
179+
this._setupPointerEnterEventsIfNeeded();
179180
}
180181
}
181182

@@ -205,17 +206,15 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
205206
@Input('matTooltip')
206207
get message() { return this._message; }
207208
set message(value: string) {
208-
if (this._message) {
209-
this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._message);
210-
}
209+
this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._message);
211210

212211
// If the message is not a string (e.g. number), convert it to a string and trim it.
213212
this._message = value != null ? `${value}`.trim() : '';
214213

215214
if (!this._message && this._isTooltipVisible()) {
216215
this.hide(0);
217216
} else {
218-
this._setupPointerEvents();
217+
this._setupPointerEnterEventsIfNeeded();
219218
this._updateTooltipMessage();
220219
this._ngZone.runOutsideAngular(() => {
221220
// The `AriaDescriber` has some functionality that avoids adding a description if it's the
@@ -241,7 +240,8 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
241240
}
242241

243242
/** Manually-bound passive event listeners. */
244-
private _passiveListeners = new Map<string, EventListenerOrEventListenerObject>();
243+
private readonly _passiveListeners:
244+
(readonly [string, EventListenerOrEventListenerObject])[] = [];
245245

246246
/** Timer started at the last `touchstart` event. */
247247
private _touchstartTimeout: number;
@@ -283,7 +283,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
283283
ngAfterViewInit() {
284284
// This needs to happen after view init so the initial values for all inputs have been set.
285285
this._viewInitialized = true;
286-
this._setupPointerEvents();
286+
this._setupPointerEnterEventsIfNeeded();
287287

288288
this._focusMonitor.monitor(this._elementRef)
289289
.pipe(takeUntil(this._destroyed))
@@ -312,10 +312,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
312312

313313
// Clean up the event listeners set in the constructor
314314
nativeElement.removeEventListener('keydown', this._handleKeydown);
315-
this._passiveListeners.forEach((listener, event) => {
315+
this._passiveListeners.forEach(([event, listener]) => {
316316
nativeElement.removeEventListener(event, listener, passiveListenerOptions);
317317
});
318-
this._passiveListeners.clear();
318+
this._passiveListeners.length = 0;
319319

320320
this._destroyed.next();
321321
this._destroyed.complete();
@@ -549,49 +549,82 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
549549
}
550550

551551
/** Binds the pointer events to the tooltip trigger. */
552-
private _setupPointerEvents() {
552+
private _setupPointerEnterEventsIfNeeded() {
553553
// Optimization: Defer hooking up events if there's no message or the tooltip is disabled.
554554
if (this._disabled || !this.message || !this._viewInitialized ||
555-
this._passiveListeners.size) {
555+
this._passiveListeners.length) {
556556
return;
557557
}
558558

559559
// The mouse events shouldn't be bound on mobile devices, because they can prevent the
560560
// first tap from firing its click event or can cause the tooltip to open for clicks.
561-
if (!this._platform.IOS && !this._platform.ANDROID) {
561+
if (this._platformSupportsMouseEvents()) {
562562
this._passiveListeners
563-
.set('mouseenter', () => this.show())
564-
.set('mouseleave', () => this.hide());
563+
.push(['mouseenter', () => {
564+
this._setupPointerExitEventsIfNeeded();
565+
this.show();
566+
}]);
567+
} else if (this.touchGestures !== 'off') {
568+
this._disableNativeGesturesIfNecessary();
569+
570+
this._passiveListeners
571+
.push(['touchstart', () => {
572+
// Note that it's important that we don't `preventDefault` here,
573+
// because it can prevent click events from firing on the element.
574+
this._setupPointerExitEventsIfNeeded();
575+
clearTimeout(this._touchstartTimeout);
576+
this._touchstartTimeout = setTimeout(() => this.show(), LONGPRESS_DELAY);
577+
}]);
578+
}
579+
580+
this._addListeners(this._passiveListeners);
581+
}
582+
583+
private _setupPointerExitEventsIfNeeded() {
584+
if (this._pointerExitEventsInitialized) {
585+
return;
586+
}
587+
this._pointerExitEventsInitialized = true;
588+
589+
const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
590+
if (this._platformSupportsMouseEvents()) {
591+
exitListeners.push(['mouseleave', () => this.hide()]);
565592
} else if (this.touchGestures !== 'off') {
566593
this._disableNativeGesturesIfNecessary();
567594
const touchendListener = () => {
568595
clearTimeout(this._touchstartTimeout);
569596
this.hide(this._defaultOptions.touchendHideDelay);
570597
};
571598

572-
this._passiveListeners
573-
.set('touchend', touchendListener)
574-
.set('touchcancel', touchendListener)
575-
.set('touchstart', () => {
576-
// Note that it's important that we don't `preventDefault` here,
577-
// because it can prevent click events from firing on the element.
578-
clearTimeout(this._touchstartTimeout);
579-
this._touchstartTimeout = setTimeout(() => this.show(), LONGPRESS_DELAY);
580-
});
599+
exitListeners.push(
600+
['touchend', touchendListener],
601+
['touchcancel', touchendListener],
602+
);
581603
}
582604

583-
this._passiveListeners.forEach((listener, event) => {
605+
this._addListeners(exitListeners);
606+
this._passiveListeners.push(...exitListeners);
607+
}
608+
609+
private _addListeners(
610+
listeners: ReadonlyArray<readonly [string, EventListenerOrEventListenerObject]>) {
611+
listeners.forEach(([event, listener]) => {
584612
this._elementRef.nativeElement.addEventListener(event, listener, passiveListenerOptions);
585613
});
586614
}
587615

616+
private _platformSupportsMouseEvents() {
617+
return !this._platform.IOS && !this._platform.ANDROID;
618+
}
619+
588620
/** Disables the native browser gestures, based on how the tooltip has been configured. */
589621
private _disableNativeGesturesIfNecessary() {
590-
const element = this._elementRef.nativeElement;
591-
const style = element.style;
592622
const gestures = this.touchGestures;
593623

594624
if (gestures !== 'off') {
625+
const element = this._elementRef.nativeElement;
626+
const style = element.style;
627+
595628
// If gestures are set to `auto`, we don't disable text selection on inputs and
596629
// textareas, because it prevents the user from typing into them on iOS Safari.
597630
if (gestures === 'on' || (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA')) {

0 commit comments

Comments
 (0)