Skip to content

Commit

Permalink
perf(tooltip): Hook up to pointer leave events when pointer enter eve…
Browse files Browse the repository at this point in the history
…nts fire (#19777)

Only adds listeners for leave events _after_ an enter event occurs, rather than eagerly.
  • Loading branch information
kseamon committed Jul 28, 2020
1 parent 75e0612 commit 04b7523
Show file tree
Hide file tree
Showing 2 changed files with 60 additions and 27 deletions.
2 changes: 1 addition & 1 deletion src/cdk/a11y/aria-describer/aria-describer.ts
Expand Up @@ -85,7 +85,7 @@ export class AriaDescriber implements OnDestroy {

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

Expand Down
85 changes: 59 additions & 26 deletions src/material/tooltip/tooltip.ts
Expand Up @@ -145,6 +145,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
private _tooltipClass: string|string[]|Set<string>|{[key: string]: any};
private _scrollStrategy: () => ScrollStrategy;
private _viewInitialized = false;
private _pointerExitEventsInitialized = false;

/** Allows the user to define the position of the tooltip relative to the parent element */
@Input('matTooltipPosition')
Expand Down Expand Up @@ -175,7 +176,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
if (this._disabled) {
this.hide(0);
} else {
this._setupPointerEvents();
this._setupPointerEnterEventsIfNeeded();
}
}

Expand Down Expand Up @@ -205,17 +206,15 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
@Input('matTooltip')
get message() { return this._message; }
set message(value: string) {
if (this._message) {
this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._message);
}
this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._message);

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

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

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

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

this._focusMonitor.monitor(this._elementRef)
.pipe(takeUntil(this._destroyed))
Expand Down Expand Up @@ -312,10 +312,10 @@ export class MatTooltip implements OnDestroy, AfterViewInit {

// Clean up the event listeners set in the constructor
nativeElement.removeEventListener('keydown', this._handleKeydown);
this._passiveListeners.forEach((listener, event) => {
this._passiveListeners.forEach(([event, listener]) => {
nativeElement.removeEventListener(event, listener, passiveListenerOptions);
});
this._passiveListeners.clear();
this._passiveListeners.length = 0;

this._destroyed.next();
this._destroyed.complete();
Expand Down Expand Up @@ -549,49 +549,82 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
}

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

// The mouse events shouldn't be bound on mobile devices, because they can prevent the
// first tap from firing its click event or can cause the tooltip to open for clicks.
if (!this._platform.IOS && !this._platform.ANDROID) {
if (this._platformSupportsMouseEvents()) {
this._passiveListeners
.set('mouseenter', () => this.show())
.set('mouseleave', () => this.hide());
.push(['mouseenter', () => {
this._setupPointerExitEventsIfNeeded();
this.show();
}]);
} else if (this.touchGestures !== 'off') {
this._disableNativeGesturesIfNecessary();

this._passiveListeners
.push(['touchstart', () => {
// Note that it's important that we don't `preventDefault` here,
// because it can prevent click events from firing on the element.
this._setupPointerExitEventsIfNeeded();
clearTimeout(this._touchstartTimeout);
this._touchstartTimeout = setTimeout(() => this.show(), LONGPRESS_DELAY);
}]);
}

this._addListeners(this._passiveListeners);
}

private _setupPointerExitEventsIfNeeded() {
if (this._pointerExitEventsInitialized) {
return;
}
this._pointerExitEventsInitialized = true;

const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
if (this._platformSupportsMouseEvents()) {
exitListeners.push(['mouseleave', () => this.hide()]);
} else if (this.touchGestures !== 'off') {
this._disableNativeGesturesIfNecessary();
const touchendListener = () => {
clearTimeout(this._touchstartTimeout);
this.hide(this._defaultOptions.touchendHideDelay);
};

this._passiveListeners
.set('touchend', touchendListener)
.set('touchcancel', touchendListener)
.set('touchstart', () => {
// Note that it's important that we don't `preventDefault` here,
// because it can prevent click events from firing on the element.
clearTimeout(this._touchstartTimeout);
this._touchstartTimeout = setTimeout(() => this.show(), LONGPRESS_DELAY);
});
exitListeners.push(
['touchend', touchendListener],
['touchcancel', touchendListener],
);
}

this._passiveListeners.forEach((listener, event) => {
this._addListeners(exitListeners);
this._passiveListeners.push(...exitListeners);
}

private _addListeners(
listeners: ReadonlyArray<readonly [string, EventListenerOrEventListenerObject]>) {
listeners.forEach(([event, listener]) => {
this._elementRef.nativeElement.addEventListener(event, listener, passiveListenerOptions);
});
}

private _platformSupportsMouseEvents() {
return !this._platform.IOS && !this._platform.ANDROID;
}

/** Disables the native browser gestures, based on how the tooltip has been configured. */
private _disableNativeGesturesIfNecessary() {
const element = this._elementRef.nativeElement;
const style = element.style;
const gestures = this.touchGestures;

if (gestures !== 'off') {
const element = this._elementRef.nativeElement;
const style = element.style;

// If gestures are set to `auto`, we don't disable text selection on inputs and
// textareas, because it prevents the user from typing into them on iOS Safari.
if (gestures === 'on' || (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA')) {
Expand Down

0 comments on commit 04b7523

Please sign in to comment.