From 27181b361bd2b06a5f5af3647573938c37550bb3 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 24 May 2020 14:12:29 +0200 Subject: [PATCH] fix(tooltip): decouple removal logic from change detection Currently the logic in the tooltip that removes it from the DOM is run either if the trigger is destroyed or the exit animation has finished. The problem is that if the trigger is detached from change detection, but hasn't been destroyed, the exit animation will never run and the element won't be cleaned up. These changes switch to using CSS animations and manipulating the DOM node directly to trigger the animation. Fixes #19365. --- src/material/tooltip/BUILD.bazel | 2 - .../tooltip/testing/tooltip-harness.ts | 7 +- src/material/tooltip/tooltip.html | 8 +- src/material/tooltip/tooltip.scss | 42 +++++ src/material/tooltip/tooltip.spec.ts | 97 +++++------- src/material/tooltip/tooltip.ts | 145 ++++++++++-------- tools/public_api_guard/material/tooltip.d.ts | 12 +- 7 files changed, 181 insertions(+), 132 deletions(-) diff --git a/src/material/tooltip/BUILD.bazel b/src/material/tooltip/BUILD.bazel index 19ac07e2e8b1..0fec509c6d9f 100644 --- a/src/material/tooltip/BUILD.bazel +++ b/src/material/tooltip/BUILD.bazel @@ -29,7 +29,6 @@ ng_module( "//src/cdk/portal", "//src/cdk/scrolling", "//src/material/core", - "@npm//@angular/animations", "@npm//@angular/common", "@npm//@angular/core", "@npm//rxjs", @@ -65,7 +64,6 @@ ng_test_library( "//src/cdk/overlay", "//src/cdk/platform", "//src/cdk/testing/private", - "@npm//@angular/animations", "@npm//@angular/platform-browser", ], ) diff --git a/src/material/tooltip/testing/tooltip-harness.ts b/src/material/tooltip/testing/tooltip-harness.ts index 2a16dbf1dff9..a91a7715b115 100644 --- a/src/material/tooltip/testing/tooltip-harness.ts +++ b/src/material/tooltip/testing/tooltip-harness.ts @@ -31,14 +31,13 @@ export class MatTooltipHarness extends ComponentHarness { /** Hides the tooltip. */ async hide(): Promise { - const host = await this.host(); - await host.mouseAway(); - await this.forceStabilize(); // Needed in order to flush the `hide` animation. + return (await this.host()).mouseAway(); } /** Gets whether the tooltip is open. */ async isOpen(): Promise { - return !!(await this._optionalPanel()); + const panel = await this._optionalPanel(); + return !!panel && !(await panel.hasClass('mat-tooltip-hide')); } /** Gets a promise for the tooltip panel's text. */ diff --git a/src/material/tooltip/tooltip.html b/src/material/tooltip/tooltip.html index feaaaf53352f..6b64bc9a1c72 100644 --- a/src/material/tooltip/tooltip.html +++ b/src/material/tooltip/tooltip.html @@ -1,6 +1,6 @@ -
{{message}}
+ (animationend)="_animationEnd($event)">{{message}} diff --git a/src/material/tooltip/tooltip.scss b/src/material/tooltip/tooltip.scss index 2779d0c3921a..af19d750abad 100644 --- a/src/material/tooltip/tooltip.scss +++ b/src/material/tooltip/tooltip.scss @@ -24,6 +24,13 @@ $mat-tooltip-handset-margin: 24px; padding-right: $mat-tooltip-horizontal-padding; overflow: hidden; text-overflow: ellipsis; + opacity: 0; + transform: scale(0); + + // Use a very short animation if animations are disabled so the `animationend` event still fires. + &._mat-animation-noopable { + animation-duration: 1ms; + } @include cdk-high-contrast(active, off) { outline: solid 1px; @@ -35,3 +42,38 @@ $mat-tooltip-handset-margin: 24px; padding-left: $mat-tooltip-handset-horizontal-padding; padding-right: $mat-tooltip-handset-horizontal-padding; } + +@keyframes mat-tooltip-show { + 0% { + opacity: 0; + transform: scale(0); + } + + 50% { + opacity: 0.5; + transform: scale(0.99); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes mat-tooltip-hide { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.mat-tooltip-show { + animation: mat-tooltip-show 200ms cubic-bezier(0, 0, 0.2, 1) forwards; +} + +.mat-tooltip-hide { + animation: mat-tooltip-hide 100ms cubic-bezier(0, 0, 0.2, 1) forwards; +} diff --git a/src/material/tooltip/tooltip.spec.ts b/src/material/tooltip/tooltip.spec.ts index bd07d16d2609..768ca509730a 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -3,7 +3,6 @@ import { ComponentFixture, fakeAsync, flush, - flushMicrotasks, inject, TestBed, tick @@ -16,9 +15,7 @@ import { ViewChild, NgZone, } from '@angular/core'; -import {AnimationEvent} from '@angular/animations'; import {By} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {OverlayContainer, OverlayModule, CdkScrollable} from '@angular/cdk/overlay'; import {Platform} from '@angular/cdk/platform'; @@ -29,6 +26,7 @@ import { dispatchMouseEvent, createKeyboardEvent, dispatchEvent, + createFakeEvent, } from '@angular/cdk/testing/private'; import {ESCAPE} from '@angular/cdk/keycodes'; import {FocusMonitor} from '@angular/cdk/a11y'; @@ -56,7 +54,7 @@ describe('MatTooltip', () => { platform = {IOS: false, isBrowser: true, ANDROID: false}; TestBed.configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [ BasicTooltipDemo, ScrollableTooltipDemo, @@ -114,12 +112,12 @@ describe('MatTooltip', () => { fixture.detectChanges(); // wait till animation has finished - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); // Make sure tooltip is shown to the user and animation has finished const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement; expect(tooltipElement instanceof HTMLElement).toBe(true); - expect(tooltipElement.style.transform).toBe('scale(1)'); + expect(tooltipElement.classList).toContain('mat-tooltip-show'); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); @@ -134,7 +132,7 @@ describe('MatTooltip', () => { expect(tooltipDirective._isTooltipVisible()).toBe(false); // On animation complete, should expect that the tooltip has been detached. - flushMicrotasks(); + finishCurrentTooltipAnimation(overlayContainerElement, false); assertTooltipInstance(tooltipDirective, false); })); @@ -144,17 +142,17 @@ describe('MatTooltip', () => { tick(0); expect(tooltipDirective._isTooltipVisible()).toBe(true); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); tooltipDirective._overlayRef!.detach(); tick(0); fixture.detectChanges(); expect(tooltipDirective._isTooltipVisible()).toBe(false); - flushMicrotasks(); assertTooltipInstance(tooltipDirective, false); tooltipDirective.show(); tick(0); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(tooltipDirective._isTooltipVisible()).toBe(true); })); @@ -177,7 +175,7 @@ describe('MatTooltip', () => { TestBed .resetTestingModule() .configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [BasicTooltipDemo], providers: [{ provide: MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -212,7 +210,7 @@ describe('MatTooltip', () => { TestBed .resetTestingModule() .configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [TooltipDemoWithoutPositionBinding], providers: [{ provide: MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -372,16 +370,7 @@ describe('MatTooltip', () => { tooltipDirective.hide(0); fixture.detectChanges(); tick(); - - // At this point the animation should be able to complete itself and trigger the - // _animationDone function, but for unknown reasons in the test infrastructure, - // this does not occur. Manually call the hook so the animation subscriptions get invoked. - tooltipDirective._tooltipInstance!._animationDone({ - fromState: 'visible', - toState: 'hidden', - totalTime: 150, - phaseName: 'done', - } as AnimationEvent); + finishCurrentTooltipAnimation(overlayContainerElement, false); expect(() => { tooltipDirective.position = 'right'; @@ -395,7 +384,7 @@ describe('MatTooltip', () => { tooltipDirective.show(); tick(0); // Tick for the show delay (default is 0) - expect(tooltipDirective._tooltipInstance!._visibility).toBe('visible'); + expect(tooltipDirective._tooltipInstance!.isVisible()).toBe(true); fixture.detectChanges(); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); @@ -472,33 +461,21 @@ describe('MatTooltip', () => { it('should not try to dispose the tooltip when destroyed and done hiding', fakeAsync(() => { tooltipDirective.show(); fixture.detectChanges(); - tick(150); + finishCurrentTooltipAnimation(overlayContainerElement, true); const tooltipDelay = 1000; tooltipDirective.hide(); tick(tooltipDelay); // Change the tooltip state to hidden and trigger animation start + finishCurrentTooltipAnimation(overlayContainerElement, false); - // Store the tooltip instance, which will be set to null after the button is hidden. - const tooltipInstance = tooltipDirective._tooltipInstance!; fixture.componentInstance.showButton = false; fixture.detectChanges(); - - // At this point the animation should be able to complete itself and trigger the - // _animationDone function, but for unknown reasons in the test infrastructure, - // this does not occur. Manually call this and verify that doing so does not - // throw an error. - tooltipInstance._animationDone({ - fromState: 'visible', - toState: 'hidden', - totalTime: 150, - phaseName: 'done', - } as AnimationEvent); })); it('should complete the afterHidden stream when tooltip is destroyed', fakeAsync(() => { tooltipDirective.show(); fixture.detectChanges(); - tick(150); + finishCurrentTooltipAnimation(overlayContainerElement, true); const spy = jasmine.createSpy('complete spy'); const subscription = tooltipDirective._tooltipInstance!.afterHidden() @@ -507,7 +484,7 @@ describe('MatTooltip', () => { tooltipDirective.hide(0); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); expect(spy).toHaveBeenCalled(); subscription.unsubscribe(); @@ -580,7 +557,7 @@ describe('MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); let tooltipWrapper = overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; @@ -589,13 +566,13 @@ describe('MatTooltip', () => { tooltipDirective.hide(0); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); dir.value = 'ltr'; tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); tooltipWrapper = overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; @@ -613,7 +590,7 @@ describe('MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(tooltipDirective._isTooltipVisible()).toBe(true); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); @@ -621,7 +598,7 @@ describe('MatTooltip', () => { document.body.click(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); fixture.detectChanges(); expect(tooltipDirective._isTooltipVisible()).toBe(false); @@ -632,10 +609,9 @@ describe('MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - document.body.click(); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); })); @@ -717,7 +693,7 @@ describe('MatTooltip', () => { tick(0); expect(tooltipDirective._isTooltipVisible()).toBe(true); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); const overlayRef = tooltipDirective._overlayRef!; @@ -727,7 +703,7 @@ describe('MatTooltip', () => { tick(0); expect(tooltipDirective._isTooltipVisible()).toBe(true); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(overlayRef.detach).not.toHaveBeenCalled(); })); @@ -864,12 +840,12 @@ describe('MatTooltip', () => { fixture.detectChanges(); // wait until animation has finished - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); // Make sure tooltip is shown to the user and animation has finished const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement; expect(tooltipElement instanceof HTMLElement).toBe(true); - expect(tooltipElement.style.transform).toBe('scale(1)'); + expect(tooltipElement.classList).toContain('mat-tooltip-show'); // After hide called, a timeout delay is created that will to hide the tooltip. const tooltipDelay = 1000; @@ -882,7 +858,7 @@ describe('MatTooltip', () => { expect(tooltipDirective._isTooltipVisible()).toBe(false); // On animation complete, should expect that the tooltip has been detached. - flushMicrotasks(); + finishCurrentTooltipAnimation(overlayContainerElement, false); assertTooltipInstance(tooltipDirective, false); })); @@ -912,9 +888,9 @@ describe('MatTooltip', () => { assertTooltipInstance(fixture.componentInstance.tooltip, false); - tick(250); // Finish the delay. + tick(500); // Finish the delay. fixture.detectChanges(); - tick(500); // Finish the animation. + finishCurrentTooltipAnimation(overlayContainerElement, true); // Finish the animation. assertTooltipInstance(fixture.componentInstance.tooltip, true); })); @@ -953,7 +929,7 @@ describe('MatTooltip', () => { fixture.detectChanges(); tick(500); // Finish the open delay. fixture.detectChanges(); - tick(500); // Finish the animation. + finishCurrentTooltipAnimation(overlayContainerElement, true); // Finish the animation. assertTooltipInstance(fixture.componentInstance.tooltip, true); dispatchFakeEvent(button, 'touchend'); @@ -963,7 +939,7 @@ describe('MatTooltip', () => { tick(500); // Finish the delay. fixture.detectChanges(); - tick(500); // Finish the exit animation. + finishCurrentTooltipAnimation(overlayContainerElement, false); // Finish the exit animation. assertTooltipInstance(fixture.componentInstance.tooltip, false); })); @@ -977,7 +953,7 @@ describe('MatTooltip', () => { fixture.detectChanges(); tick(500); // Finish the open delay. fixture.detectChanges(); - tick(500); // Finish the animation. + finishCurrentTooltipAnimation(overlayContainerElement, true); // Finish the animation. assertTooltipInstance(fixture.componentInstance.tooltip, true); dispatchFakeEvent(button, 'touchcancel'); @@ -987,7 +963,7 @@ describe('MatTooltip', () => { tick(500); // Finish the delay. fixture.detectChanges(); - tick(500); // Finish the exit animation. + finishCurrentTooltipAnimation(overlayContainerElement, false); // Finish the exit animation. assertTooltipInstance(fixture.componentInstance.tooltip, false); })); @@ -1229,3 +1205,12 @@ function assertTooltipInstance(tooltip: MatTooltip, shouldExist: boolean): void // happens due to the `_tooltipInstance` having a circular structure. expect(!!tooltip._tooltipInstance).toBe(shouldExist); } + +function finishCurrentTooltipAnimation(overlayContainer: HTMLElement, isVisible: boolean) { + const tooltip = overlayContainer.querySelector('.mat-tooltip')!; + const event = createFakeEvent('animationend'); + Object.defineProperty(event, 'animationName', { + get: () => `mat-tooltip-${isVisible ? 'show' : 'hide'}` + }); + dispatchEvent(tooltip, event); +} diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index a5409b3fbb2d..9d0e03d213cd 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -5,7 +5,6 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {AnimationEvent} from '@angular/animations'; import {AriaDescriber, FocusMonitor} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {BooleanInput, coerceBooleanProperty, NumberInput} from '@angular/cdk/coercion'; @@ -39,12 +38,12 @@ import { ViewContainerRef, ViewEncapsulation, AfterViewInit, + ViewChild, } from '@angular/core'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import {Observable, Subject} from 'rxjs'; import {take, takeUntil} from 'rxjs/operators'; -import {matTooltipAnimations} from './tooltip-animations'; - /** Possible positions for a tooltip. */ export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after'; @@ -285,7 +284,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { if (!origin) { this._ngZone.run(() => this.hide(0)); } else if (origin === 'keyboard') { - this._ngZone.run(() => this.show()); + this._ngZone.run(() => this._show(true)); } }); } @@ -319,21 +318,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { /** Shows the tooltip after the delay in ms, defaults to tooltip-delay-show or 0ms if no input */ show(delay: number = this.showDelay): void { - if (this.disabled || !this.message || (this._isTooltipVisible() && - !this._tooltipInstance!._showTimeoutId && !this._tooltipInstance!._hideTimeoutId)) { - return; - } - - const overlayRef = this._createOverlay(); - this._detach(); - this._portal = this._portal || new ComponentPortal(TooltipComponent, this._viewContainerRef); - this._tooltipInstance = overlayRef.attach(this._portal).instance; - this._tooltipInstance.afterHidden() - .pipe(takeUntil(this._destroyed)) - .subscribe(() => this._detach()); - this._setTooltipClass(this._tooltipClass); - this._updateTooltipMessage(); - this._tooltipInstance!.show(delay); + this._show(false, delay); } /** Hides the tooltip after the delay in ms, defaults to tooltip-delay-hide or 0ms if no input */ @@ -345,7 +330,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { /** Shows/hides the tooltip */ toggle(): void { - this._isTooltipVisible() ? this.hide() : this.show(); + this._isTooltipVisible() ? this.hide() : this._show(false); } /** Returns true if the tooltip is currently visible to the user */ @@ -547,7 +532,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { // first tap from firing its click event or can cause the tooltip to open for clicks. if (!this._platform.IOS && !this._platform.ANDROID) { this._passiveListeners - .set('mouseenter', () => this.show()) + .set('mouseenter', () => this._show(true)) .set('mouseleave', () => this.hide()); } else if (this.touchGestures !== 'off') { this._disableNativeGesturesIfNecessary(); @@ -563,7 +548,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { // 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); + this._touchstartTimeout = setTimeout(() => this._show(true), LONGPRESS_DELAY); }); } @@ -597,6 +582,33 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } } + /** + * Attaches the tooltip to the DOM and shows it. + * @param isUserInteraction Whether the showing was triggered by a user interaction or + * programmatically. We have to know this in order to figure out whether clicking outside + * show hide the tooltip. + * @param delay Time in milliseconds to wait for showing the tooltip. + */ + private _show(isUserInteraction: boolean, delay = this.showDelay) { + if (this.disabled || !this.message || (this._isTooltipVisible() && + !this._tooltipInstance!._showTimeoutId && !this._tooltipInstance!._hideTimeoutId)) { + return; + } + + const overlayRef = this._createOverlay(); + + this._detach(); + this._portal = this._portal || new ComponentPortal(TooltipComponent, this._viewContainerRef); + this._tooltipInstance = overlayRef.attach(this._portal).instance; + this._tooltipInstance.afterHidden() + .pipe(takeUntil(this._destroyed)) + .subscribe(() => this._detach()); + this._setTooltipClass(this._tooltipClass); + this._updateTooltipMessage(); + this._tooltipInstance!.show(delay, isUserInteraction); + + } + static ngAcceptInputType_disabled: BooleanInput; static ngAcceptInputType_hideDelay: NumberInput; static ngAcceptInputType_showDelay: NumberInput; @@ -612,12 +624,11 @@ export class MatTooltip implements OnDestroy, AfterViewInit { styleUrls: ['tooltip.css'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - animations: [matTooltipAnimations.tooltipState], host: { // Forces the element to have a layout in IE and Edge. This fixes issues where the element // won't be rendered if the animations are disabled or there is no web animations polyfill. - '[style.zoom]': '_visibility === "visible" ? 1 : null', - '(body:click)': 'this._handleBodyInteraction()', + '[style.zoom]': 'isVisible() ? 1 : null', + '(body:click)': '_handleBodyInteraction()', 'aria-hidden': 'true', } }) @@ -634,27 +645,35 @@ export class TooltipComponent implements OnDestroy { /** The timeout ID of any current timer set to hide the tooltip */ _hideTimeoutId: number | null; - /** Property watched by the animation framework to show or hide the tooltip */ - _visibility: TooltipVisibility = 'initial'; - /** Whether interactions on the page should close the tooltip */ - private _closeOnInteraction: boolean = false; + private _closeOnInteraction = false; + + /** Whether the tooltip is currently visible. */ + private _isVisible = false; /** Subject for notifying that the tooltip has been hidden from the view */ - private readonly _onHide: Subject = new Subject(); + private readonly _onHide = new Subject(); /** Stream that emits whether the user has a handset-sized display. */ _isHandset: Observable = this._breakpointObserver.observe(Breakpoints.Handset); + /** Reference to the internal tooltip element. */ + @ViewChild('tooltip', { + // Use a static query here since we interact directly with + // the DOM which can happen before `ngAfterViewInit`. + static: true + }) _tooltip: ElementRef; + constructor( private _changeDetectorRef: ChangeDetectorRef, - private _breakpointObserver: BreakpointObserver) {} + private _breakpointObserver: BreakpointObserver, + @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string) {} /** * Shows the tooltip with an animation originating from the provided origin * @param delay Amount of milliseconds to the delay showing the tooltip. */ - show(delay: number): void { + show(delay: number, isUserInteraction?: boolean): void { // Cancel the delayed hide if it is scheduled if (this._hideTimeoutId) { clearTimeout(this._hideTimeoutId); @@ -662,14 +681,13 @@ export class TooltipComponent implements OnDestroy { } // Body interactions should cancel the tooltip if there is a delay in showing. - this._closeOnInteraction = true; + if (isUserInteraction) { + this._closeOnInteraction = true; + } + this._showTimeoutId = setTimeout(() => { - this._visibility = 'visible'; + this._toogleVisibility(true); this._showTimeoutId = null; - - // Mark for check so if any parent component has set the - // ChangeDetectionStrategy to OnPush it will be checked anyways - this._markForCheck(); }, delay); } @@ -685,12 +703,8 @@ export class TooltipComponent implements OnDestroy { } this._hideTimeoutId = setTimeout(() => { - this._visibility = 'hidden'; + this._toogleVisibility(false); this._hideTimeoutId = null; - - // Mark for check so if any parent component has set the - // ChangeDetectionStrategy to OnPush it will be checked anyways - this._markForCheck(); }, delay); } @@ -701,29 +715,13 @@ export class TooltipComponent implements OnDestroy { /** Whether the tooltip is being displayed. */ isVisible(): boolean { - return this._visibility === 'visible'; + return this._isVisible; } ngOnDestroy() { this._onHide.complete(); } - _animationStart() { - this._closeOnInteraction = false; - } - - _animationDone(event: AnimationEvent): void { - const toState = event.toState as TooltipVisibility; - - if (toState === 'hidden' && !this.isVisible()) { - this._onHide.next(); - } - - if (toState === 'visible' || toState === 'hidden') { - this._closeOnInteraction = true; - } - } - /** * Interactions on the HTML body should close the tooltip immediately as defined in the * material design spec. @@ -743,4 +741,31 @@ export class TooltipComponent implements OnDestroy { _markForCheck(): void { this._changeDetectorRef.markForCheck(); } + + /** Event listener dispatched when an animation on the tooltip finishes. */ + _animationEnd(event: AnimationEvent) { + const toVisible = event.animationName === 'mat-tooltip-show'; + const toHidden = event.animationName === 'mat-tooltip-hide'; + + if (toHidden && !this.isVisible()) { + this._onHide.next(); + } + + if (toVisible || toHidden) { + this._closeOnInteraction = true; + } + } + + /** Toggles the visibility of the tooltip element. */ + private _toogleVisibility(isVisible: boolean) { + // We set the classes directly here ourselves so that toggling the tooltip state + // isn't bound by change detection. This allows us to hide it even if the + // view ref has been detached from the CD tree. + const classList = this._tooltip.nativeElement.classList; + const showClass = 'mat-tooltip-show'; + const hideClass = 'mat-tooltip-hide'; + classList.remove(isVisible ? hideClass : showClass); + classList.add(isVisible ? showClass : hideClass); + this._isVisible = isVisible; + } } diff --git a/tools/public_api_guard/material/tooltip.d.ts b/tools/public_api_guard/material/tooltip.d.ts index fecc9559055f..22939678a1c9 100644 --- a/tools/public_api_guard/material/tooltip.d.ts +++ b/tools/public_api_guard/material/tooltip.d.ts @@ -76,26 +76,26 @@ export declare const SCROLL_THROTTLE_MS = 20; export declare const TOOLTIP_PANEL_CLASS = "mat-tooltip-panel"; export declare class TooltipComponent implements OnDestroy { + _animationMode?: string | undefined; _hideTimeoutId: number | null; _isHandset: Observable; _showTimeoutId: number | null; - _visibility: TooltipVisibility; + _tooltip: ElementRef; message: string; tooltipClass: string | string[] | Set | { [key: string]: any; }; - constructor(_changeDetectorRef: ChangeDetectorRef, _breakpointObserver: BreakpointObserver); - _animationDone(event: AnimationEvent): void; - _animationStart(): void; + constructor(_changeDetectorRef: ChangeDetectorRef, _breakpointObserver: BreakpointObserver, _animationMode?: string | undefined); + _animationEnd(event: AnimationEvent): void; _handleBodyInteraction(): void; _markForCheck(): void; afterHidden(): Observable; hide(delay: number): void; isVisible(): boolean; ngOnDestroy(): void; - show(delay: number): void; + show(delay: number, isUserInteraction?: boolean): void; static ɵcmp: i0.ɵɵComponentDefWithMeta; - static ɵfac: i0.ɵɵFactoryDef; + static ɵfac: i0.ɵɵFactoryDef; } export declare type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after';