Skip to content

perf(tooltip): Hook up to pointer leave events when pointer enter events fire #19777

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cdk/a11y/aria-describer/aria-describer.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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