diff --git a/src/cdk/a11y/aria-describer/aria-describer.ts b/src/cdk/a11y/aria-describer/aria-describer.ts index ae31a20f575e..c84e81d3cbe8 100644 --- a/src/cdk/a11y/aria-describer/aria-describer.ts +++ b/src/cdk/a11y/aria-describer/aria-describer.ts @@ -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; } diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index c92e598bf966..b1b1d739065c 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -145,6 +145,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { private _tooltipClass: string|string[]|Set|{[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') @@ -175,7 +176,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { if (this._disabled) { this.hide(0); } else { - this._setupPointerEvents(); + this._setupPointerEnterEventsIfNeeded(); } } @@ -205,9 +206,7 @@ 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() : ''; @@ -215,7 +214,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { 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 @@ -241,7 +240,8 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } /** Manually-bound passive event listeners. */ - private _passiveListeners = new Map(); + private readonly _passiveListeners: + (readonly [string, EventListenerOrEventListenerObject])[] = []; /** Timer started at the last `touchstart` event. */ private _touchstartTimeout: number; @@ -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)) @@ -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(); @@ -549,19 +549,46 @@ 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 = () => { @@ -569,29 +596,35 @@ export class MatTooltip implements OnDestroy, AfterViewInit { 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) { + 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')) {