From 27cf6761bff1b7d52f3b771d1d53fee53b2195de Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sat, 5 Mar 2022 14:51:16 +0100 Subject: [PATCH] fix(material/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. --- .../mdc-tooltip/testing/tooltip-harness.ts | 3 + .../mdc-tooltip/tooltip.html | 7 +- .../mdc-tooltip/tooltip.scss | 40 ++++ .../mdc-tooltip/tooltip.spec.ts | 116 ++++----- .../mdc-tooltip/tooltip.ts | 30 ++- src/material/tooltip/BUILD.bazel | 2 - .../tooltip/testing/tooltip-harness.ts | 20 +- src/material/tooltip/tooltip.html | 9 +- src/material/tooltip/tooltip.scss | 43 ++++ src/material/tooltip/tooltip.spec.ts | 112 ++++----- src/material/tooltip/tooltip.ts | 220 ++++++++++++------ .../material/tooltip-testing.md | 12 + tools/public_api_guard/material/tooltip.md | 31 ++- 13 files changed, 418 insertions(+), 227 deletions(-) diff --git a/src/material-experimental/mdc-tooltip/testing/tooltip-harness.ts b/src/material-experimental/mdc-tooltip/testing/tooltip-harness.ts index 62596cba1967..2761b4ffdf5a 100644 --- a/src/material-experimental/mdc-tooltip/testing/tooltip-harness.ts +++ b/src/material-experimental/mdc-tooltip/testing/tooltip-harness.ts @@ -14,6 +14,9 @@ export class MatTooltipHarness extends _MatTooltipHarnessBase { protected _optionalPanel = this.documentRootLocatorFactory().locatorForOptional('.mat-mdc-tooltip'); static hostSelector = '.mat-mdc-tooltip-trigger'; + protected _hiddenClass = 'mat-mdc-tooltip-hide'; + protected _showAnimationName = 'mat-mdc-tooltip-show'; + protected _hideAnimationName = 'mat-mdc-tooltip-hide'; /** * Gets a `HarnessPredicate` that can be used to search diff --git a/src/material-experimental/mdc-tooltip/tooltip.html b/src/material-experimental/mdc-tooltip/tooltip.html index 6eff2c6af2c9..db31395818c5 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.html +++ b/src/material-experimental/mdc-tooltip/tooltip.html @@ -1,9 +1,8 @@
+ [class._mat-animation-noopable]="_animationsDisabled" + [class.mdc-tooltip--multiline]="_isMultiline">
{{message}}
diff --git a/src/material-experimental/mdc-tooltip/tooltip.scss b/src/material-experimental/mdc-tooltip/tooltip.scss index 07c0a9af2dba..18b6103cfd9c 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.scss +++ b/src/material-experimental/mdc-tooltip/tooltip.scss @@ -6,6 +6,7 @@ .mat-mdc-tooltip { // We don't use MDC's positioning so this has to be relative. position: relative; + transform: scale(0); // Increases the area of the tooltip so the user's pointer can go from the trigger directly to it. &::before { @@ -18,8 +19,47 @@ z-index: -1; position: absolute; } + + &._mat-animation-noopable { + animation: none; + transform: none; + } } .mat-mdc-tooltip-panel-non-interactive { pointer-events: none; } + +// TODO(crisbeto): we may be able to use MDC directly for these animations. + +@keyframes mat-mdc-tooltip-show { + 0% { + opacity: 0; + transform: scale(0.8); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes mat-mdc-tooltip-hide { + 0% { + opacity: 1; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(0.8); + } +} + +.mat-mdc-tooltip-show { + animation: mat-mdc-tooltip-show 150ms cubic-bezier(0, 0, 0.2, 1) forwards; +} + +.mat-mdc-tooltip-hide { + animation: mat-mdc-tooltip-hide 75ms cubic-bezier(0.4, 0, 1, 1) forwards; +} diff --git a/src/material-experimental/mdc-tooltip/tooltip.spec.ts b/src/material-experimental/mdc-tooltip/tooltip.spec.ts index 020a0f7674c0..4344c487301d 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.spec.ts +++ b/src/material-experimental/mdc-tooltip/tooltip.spec.ts @@ -1,4 +1,3 @@ -import {AnimationEvent} from '@angular/animations'; import {FocusMonitor} from '@angular/cdk/a11y'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {ESCAPE} from '@angular/cdk/keycodes'; @@ -26,14 +25,12 @@ import { ComponentFixture, fakeAsync, flush, - flushMicrotasks, inject, TestBed, tick, waitForAsync, } from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Subject} from 'rxjs'; import { MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -43,6 +40,7 @@ import { TooltipPosition, TooltipTouchGestures, } from './index'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; const initialTooltipMessage = 'initial tooltip message'; @@ -55,7 +53,7 @@ describe('MDC-based MatTooltip', () => { beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [ BasicTooltipDemo, ScrollableTooltipDemo, @@ -111,19 +109,19 @@ describe('MDC-based MatTooltip', () => { fixture.detectChanges(); - // wait till animation has finished - tick(500); + // Wait until animation has finished + finishCurrentTooltipAnimation(overlayContainerElement, true); - // Make sure tooltip is shown to the user and animation has finished + // Make sure tooltip is shown to the user and animation has finished. const tooltipElement = overlayContainerElement.querySelector( '.mat-mdc-tooltip', ) as HTMLElement; expect(tooltipElement instanceof HTMLElement).toBe(true); - expect(tooltipElement.style.transform).toBe('scale(1)'); + expect(tooltipElement.classList).toContain('mat-mdc-tooltip-show'); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); - // After hide called, a timeout delay is created that will to hide the tooltip. + // After hide is called, a timeout delay is created that will to hide the tooltip. const tooltipDelay = 1000; tooltipDirective.hide(tooltipDelay); expect(tooltipDirective._isTooltipVisible()).toBe(true); @@ -134,7 +132,7 @@ describe('MDC-based MatTooltip', () => { expect(tooltipDirective._isTooltipVisible()).toBe(false); // On animation complete, should expect that the tooltip has been detached. - flushMicrotasks(); + finishCurrentTooltipAnimation(overlayContainerElement, false); assertTooltipInstance(tooltipDirective, false); })); @@ -143,17 +141,17 @@ describe('MDC-based 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); })); @@ -175,7 +173,7 @@ describe('MDC-based MatTooltip', () => { it('should be able to override the default show and hide delays', fakeAsync(() => { TestBed.resetTestingModule() .configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [BasicTooltipDemo], providers: [ { @@ -212,7 +210,7 @@ describe('MDC-based MatTooltip', () => { it('should be able to override the default position', fakeAsync(() => { TestBed.resetTestingModule() .configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [TooltipDemoWithoutPositionBinding], providers: [ { @@ -421,16 +419,7 @@ describe('MDC-based 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'; @@ -444,7 +433,7 @@ describe('MDC-based 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); @@ -530,33 +519,21 @@ describe('MDC-based 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 @@ -566,7 +543,7 @@ describe('MDC-based MatTooltip', () => { tooltipDirective.hide(0); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); expect(spy).toHaveBeenCalled(); subscription.unsubscribe(); @@ -642,7 +619,7 @@ describe('MDC-based MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); let tooltipWrapper = overlayContainerElement.querySelector( '.cdk-overlay-connected-position-bounding-box', @@ -654,13 +631,13 @@ describe('MDC-based 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', @@ -681,7 +658,7 @@ describe('MDC-based MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(tooltipDirective._isTooltipVisible()).toBe(true); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); @@ -689,7 +666,7 @@ describe('MDC-based MatTooltip', () => { document.body.click(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); fixture.detectChanges(); expect(tooltipDirective._isTooltipVisible()).toBe(false); @@ -700,7 +677,7 @@ describe('MDC-based MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(tooltipDirective._isTooltipVisible()).toBe(true); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); @@ -708,7 +685,7 @@ describe('MDC-based MatTooltip', () => { dispatchFakeEvent(document.body, 'auxclick'); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); fixture.detectChanges(); expect(tooltipDirective._isTooltipVisible()).toBe(false); @@ -719,10 +696,9 @@ describe('MDC-based MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - document.body.click(); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); })); @@ -741,6 +717,7 @@ describe('MDC-based MatTooltip', () => { fixture.detectChanges(); tick(500); fixture.detectChanges(); + finishCurrentTooltipAnimation(overlayContainerElement, false); expect(tooltipDirective._isTooltipVisible()).toBe(false); expect(overlayContainerElement.textContent).toBe(''); @@ -822,7 +799,7 @@ describe('MDC-based MatTooltip', () => { tick(0); expect(tooltipDirective._isTooltipVisible()).toBe(true); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); const overlayRef = tooltipDirective._overlayRef!; @@ -832,7 +809,7 @@ describe('MDC-based MatTooltip', () => { tick(0); expect(tooltipDirective._isTooltipVisible()).toBe(true); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(overlayRef.detach).not.toHaveBeenCalled(); })); @@ -1176,14 +1153,14 @@ describe('MDC-based 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-mdc-tooltip', ) as HTMLElement; expect(tooltipElement instanceof HTMLElement).toBe(true); - expect(tooltipElement.style.transform).toBe('scale(1)'); + expect(tooltipElement.classList).toContain('mat-mdc-tooltip-show'); // After hide called, a timeout delay is created that will to hide the tooltip. const tooltipDelay = 1000; @@ -1196,7 +1173,7 @@ describe('MDC-based MatTooltip', () => { expect(tooltipDirective._isTooltipVisible()).toBe(false); // On animation complete, should expect that the tooltip has been detached. - flushMicrotasks(); + finishCurrentTooltipAnimation(overlayContainerElement, false); assertTooltipInstance(tooltipDirective, false); })); @@ -1233,9 +1210,9 @@ describe('MDC-based 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); })); @@ -1274,7 +1251,7 @@ describe('MDC-based 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'); @@ -1284,7 +1261,7 @@ describe('MDC-based 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); })); @@ -1298,7 +1275,7 @@ describe('MDC-based 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'); @@ -1308,7 +1285,7 @@ describe('MDC-based 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); })); @@ -1431,7 +1408,7 @@ describe('MDC-based MatTooltip', () => { fixture.detectChanges(); tick(500); // Finish the open delay. fixture.detectChanges(); - tick(500); // Finish the animation. + finishCurrentTooltipAnimation(overlayContainerElement, true); assertTooltipInstance(fixture.componentInstance.tooltip, true); // Simulate the pointer at the bottom/right of the page. @@ -1445,7 +1422,7 @@ describe('MDC-based MatTooltip', () => { fixture.detectChanges(); tick(1500); // Finish the delay. fixture.detectChanges(); - tick(500); // Finish the exit animation. + finishCurrentTooltipAnimation(overlayContainerElement, false); assertTooltipInstance(fixture.componentInstance.tooltip, false); })); @@ -1464,7 +1441,7 @@ describe('MDC-based MatTooltip', () => { fixture.detectChanges(); tick(500); // Finish the open delay. fixture.detectChanges(); - tick(500); // Finish the animation. + finishCurrentTooltipAnimation(overlayContainerElement, true); assertTooltipInstance(fixture.componentInstance.tooltip, true); // Simulate the pointer over the trigger. @@ -1479,7 +1456,7 @@ describe('MDC-based MatTooltip', () => { fixture.detectChanges(); tick(1500); // Finish the delay. fixture.detectChanges(); - tick(500); // Finish the exit animation. + finishCurrentTooltipAnimation(overlayContainerElement, false); assertTooltipInstance(fixture.componentInstance.tooltip, true); })); @@ -1621,3 +1598,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-mdc-tooltip')!; + const event = createFakeEvent('animationend'); + Object.defineProperty(event, 'animationName', { + get: () => `mat-mdc-tooltip-${isVisible ? 'show' : 'hide'}`, + }); + dispatchEvent(tooltip, event); +} diff --git a/src/material-experimental/mdc-tooltip/tooltip.ts b/src/material-experimental/mdc-tooltip/tooltip.ts index cc0a49ba4d42..05cff8eaa0ed 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.ts +++ b/src/material-experimental/mdc-tooltip/tooltip.ts @@ -15,11 +15,13 @@ import { Inject, NgZone, Optional, + ViewChild, ViewContainerRef, ViewEncapsulation, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; import {Platform} from '@angular/cdk/platform'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import {AriaDescriber, FocusMonitor} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {ConnectedPosition, Overlay, ScrollDispatcher} from '@angular/cdk/overlay'; @@ -31,7 +33,6 @@ import { _TooltipComponentBase, } from '@angular/material/tooltip'; import {numbers} from '@material/tooltip'; -import {matTooltipAnimations} from './tooltip-animations'; /** * CSS class that will be attached to the overlay panel. @@ -116,11 +117,10 @@ export class MatTooltip extends _MatTooltipBase { 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', + '[style.zoom]': 'isVisible() ? 1 : null', '(mouseleave)': '_handleMouseLeave($event)', 'aria-hidden': 'true', }, @@ -129,12 +129,32 @@ export class TooltipComponent extends _TooltipComponentBase { /* Whether the tooltip text overflows to multiple lines */ _isMultiline = false; - constructor(changeDetectorRef: ChangeDetectorRef, private _elementRef: ElementRef) { - super(changeDetectorRef); + /** 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; + _showAnimation = 'mat-mdc-tooltip-show'; + _hideAnimation = 'mat-mdc-tooltip-hide'; + + constructor( + changeDetectorRef: ChangeDetectorRef, + private _elementRef: ElementRef, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, + /** + * @deprecated _ngZone parameter to become required. + * @breaking-change 13.0.0 + */ + ngZone?: NgZone, + ) { + super(changeDetectorRef, animationMode, ngZone); } protected override _onShow(): void { this._isMultiline = this._isTooltipMultiline(); + this._markForCheck(); } /** Whether the tooltip text has overflown to the next line */ diff --git a/src/material/tooltip/BUILD.bazel b/src/material/tooltip/BUILD.bazel index deb3e9d9fe9b..656cf3342018 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", "@npm//rxjs", ], diff --git a/src/material/tooltip/testing/tooltip-harness.ts b/src/material/tooltip/testing/tooltip-harness.ts index 6940760d8752..bd1ce13c833f 100644 --- a/src/material/tooltip/testing/tooltip-harness.ts +++ b/src/material/tooltip/testing/tooltip-harness.ts @@ -16,6 +16,9 @@ import {TooltipHarnessFilters} from './tooltip-harness-filters'; export abstract class _MatTooltipHarnessBase extends ComponentHarness { protected abstract _optionalPanel: AsyncFactoryFn; + protected abstract _hiddenClass: string; + protected abstract _showAnimationName: string; + protected abstract _hideAnimationName: string; /** Shows the tooltip. */ async show(): Promise { @@ -24,9 +27,10 @@ export abstract class _MatTooltipHarnessBase extends ComponentHarness { // We need to dispatch both `touchstart` and a hover event, because the tooltip binds // different events depending on the device. The `changedTouches` is there in case the // element has ripples. - // @breaking-change 12.0.0 Remove null assertion from `dispatchEvent`. - await host.dispatchEvent?.('touchstart', {changedTouches: []}); + await host.dispatchEvent('touchstart', {changedTouches: []}); await host.hover(); + const panel = await this._optionalPanel(); + await panel?.dispatchEvent('animationend', {animationName: this._showAnimationName}); } /** Hides the tooltip. */ @@ -35,15 +39,16 @@ export abstract class _MatTooltipHarnessBase extends ComponentHarness { // We need to dispatch both `touchstart` and a hover event, because // the tooltip binds different events depending on the device. - // @breaking-change 12.0.0 Remove null assertion from `dispatchEvent`. - await host.dispatchEvent?.('touchend'); + await host.dispatchEvent('touchend'); await host.mouseAway(); - await this.forceStabilize(); // Needed in order to flush the `hide` animation. + const panel = await this._optionalPanel(); + await panel?.dispatchEvent('animationend', {animationName: this._hideAnimationName}); } /** Gets whether the tooltip is open. */ async isOpen(): Promise { - return !!(await this._optionalPanel()); + const panel = await this._optionalPanel(); + return !!panel && !(await panel.hasClass(this._hiddenClass)); } /** Gets a promise for the tooltip panel's text. */ @@ -56,6 +61,9 @@ export abstract class _MatTooltipHarnessBase extends ComponentHarness { /** Harness for interacting with a standard mat-tooltip in tests. */ export class MatTooltipHarness extends _MatTooltipHarnessBase { protected _optionalPanel = this.documentRootLocatorFactory().locatorForOptional('.mat-tooltip'); + protected _hiddenClass = 'mat-tooltip-hide'; + protected _showAnimationName = 'mat-tooltip-show'; + protected _hideAnimationName = 'mat-tooltip-hide'; static hostSelector = '.mat-tooltip-trigger'; /** diff --git a/src/material/tooltip/tooltip.html b/src/material/tooltip/tooltip.html index feaaaf53352f..7f0dc3c5dcfd 100644 --- a/src/material/tooltip/tooltip.html +++ b/src/material/tooltip/tooltip.html @@ -1,6 +1,5 @@ -
{{message}}
+ [class._mat-animation-noopable]="_animationsDisabled" + [class.mat-tooltip-handset]="(_isHandset | async)?.matches">{{message}} diff --git a/src/material/tooltip/tooltip.scss b/src/material/tooltip/tooltip.scss index 1d4c9eeb4b64..b5b430d0ca59 100644 --- a/src/material/tooltip/tooltip.scss +++ b/src/material/tooltip/tooltip.scss @@ -16,6 +16,12 @@ $handset-margin: 24px; padding-right: $horizontal-padding; overflow: hidden; text-overflow: ellipsis; + transform: scale(0); + + &._mat-animation-noopable { + animation: none; + transform: none; + } @include a11y.high-contrast(active, off) { outline: solid 1px; @@ -31,3 +37,40 @@ $handset-margin: 24px; .mat-tooltip-panel-non-interactive { pointer-events: none; } + +@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; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(1); + } +} + +.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 759f642475a3..bc238871870c 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -1,4 +1,3 @@ -import {AnimationEvent} from '@angular/animations'; import {FocusMonitor} from '@angular/cdk/a11y'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {ESCAPE} from '@angular/cdk/keycodes'; @@ -26,14 +25,12 @@ import { ComponentFixture, fakeAsync, flush, - flushMicrotasks, inject, TestBed, tick, waitForAsync, } from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Subject} from 'rxjs'; import { MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -43,6 +40,7 @@ import { TooltipPosition, TooltipTouchGestures, } from './index'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; const initialTooltipMessage = 'initial tooltip message'; @@ -55,7 +53,7 @@ describe('MatTooltip', () => { beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [ BasicTooltipDemo, ScrollableTooltipDemo, @@ -111,13 +109,13 @@ describe('MatTooltip', () => { fixture.detectChanges(); - // Wait until the animation has finished. - tick(500); + // Wait until animation has finished + 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); @@ -132,7 +130,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); })); @@ -141,17 +139,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); })); @@ -173,7 +171,7 @@ describe('MatTooltip', () => { it('should be able to override the default show and hide delays', fakeAsync(() => { TestBed.resetTestingModule() .configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [BasicTooltipDemo], providers: [ { @@ -210,7 +208,7 @@ describe('MatTooltip', () => { it('should be able to override the default position', fakeAsync(() => { TestBed.resetTestingModule() .configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + imports: [MatTooltipModule, OverlayModule], declarations: [TooltipDemoWithoutPositionBinding], providers: [ { @@ -419,16 +417,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'; @@ -442,7 +431,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); @@ -526,33 +515,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 @@ -562,7 +539,7 @@ describe('MatTooltip', () => { tooltipDirective.hide(0); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); expect(spy).toHaveBeenCalled(); subscription.unsubscribe(); @@ -638,7 +615,7 @@ describe('MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); let tooltipWrapper = overlayContainerElement.querySelector( '.cdk-overlay-connected-position-bounding-box', @@ -650,13 +627,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', @@ -677,7 +654,7 @@ describe('MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(tooltipDirective._isTooltipVisible()).toBe(true); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); @@ -685,7 +662,7 @@ describe('MatTooltip', () => { document.body.click(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); fixture.detectChanges(); expect(tooltipDirective._isTooltipVisible()).toBe(false); @@ -696,7 +673,7 @@ describe('MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(tooltipDirective._isTooltipVisible()).toBe(true); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); @@ -704,7 +681,7 @@ describe('MatTooltip', () => { dispatchFakeEvent(document.body, 'auxclick'); tick(0); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, false); fixture.detectChanges(); expect(tooltipDirective._isTooltipVisible()).toBe(false); @@ -715,10 +692,9 @@ describe('MatTooltip', () => { tooltipDirective.show(); tick(0); fixture.detectChanges(); - document.body.click(); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); })); @@ -737,6 +713,7 @@ describe('MatTooltip', () => { fixture.detectChanges(); tick(500); fixture.detectChanges(); + finishCurrentTooltipAnimation(overlayContainerElement, false); expect(tooltipDirective._isTooltipVisible()).toBe(false); expect(overlayContainerElement.textContent).toBe(''); @@ -818,7 +795,7 @@ describe('MatTooltip', () => { tick(0); expect(tooltipDirective._isTooltipVisible()).toBe(true); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); const overlayRef = tooltipDirective._overlayRef!; @@ -828,7 +805,7 @@ describe('MatTooltip', () => { tick(0); expect(tooltipDirective._isTooltipVisible()).toBe(true); fixture.detectChanges(); - tick(500); + finishCurrentTooltipAnimation(overlayContainerElement, true); expect(overlayRef.detach).not.toHaveBeenCalled(); })); @@ -1147,12 +1124,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; @@ -1165,7 +1142,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); })); @@ -1200,9 +1177,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); })); @@ -1241,7 +1218,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'); @@ -1251,7 +1228,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); })); @@ -1265,7 +1242,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'); @@ -1275,7 +1252,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); })); @@ -1398,7 +1375,7 @@ describe('MatTooltip', () => { fixture.detectChanges(); tick(500); // Finish the open delay. fixture.detectChanges(); - tick(500); // Finish the animation. + finishCurrentTooltipAnimation(overlayContainerElement, true); assertTooltipInstance(fixture.componentInstance.tooltip, true); // Simulate the pointer at the bottom/right of the page. @@ -1412,7 +1389,7 @@ describe('MatTooltip', () => { fixture.detectChanges(); tick(1500); // Finish the delay. fixture.detectChanges(); - tick(500); // Finish the exit animation. + finishCurrentTooltipAnimation(overlayContainerElement, false); assertTooltipInstance(fixture.componentInstance.tooltip, false); })); @@ -1431,7 +1408,7 @@ describe('MatTooltip', () => { fixture.detectChanges(); tick(500); // Finish the open delay. fixture.detectChanges(); - tick(500); // Finish the animation. + finishCurrentTooltipAnimation(overlayContainerElement, true); assertTooltipInstance(fixture.componentInstance.tooltip, true); // Simulate the pointer over the trigger. @@ -1446,7 +1423,7 @@ describe('MatTooltip', () => { fixture.detectChanges(); tick(1500); // Finish the delay. fixture.detectChanges(); - tick(500); // Finish the exit animation. + finishCurrentTooltipAnimation(overlayContainerElement, false); assertTooltipInstance(fixture.componentInstance.tooltip, true); })); @@ -1588,3 +1565,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 4299911474c7..8983d95e1786 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 { @@ -46,13 +45,13 @@ import { ViewContainerRef, ViewEncapsulation, AfterViewInit, + ViewChild, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; +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'; @@ -345,7 +344,7 @@ export abstract class _MatTooltipBase if (!origin) { this._ngZone.run(() => this.hide(0)); } else if (origin === 'keyboard') { - this._ngZone.run(() => this.show()); + this._ngZone.run(() => this._show(true)); } }); } @@ -378,30 +377,7 @@ export abstract class _MatTooltipBase /** 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(this._tooltipComponent, this._viewContainerRef); - const instance = (this._tooltipInstance = overlayRef.attach(this._portal).instance); - instance._triggerElement = this._elementRef.nativeElement; - instance._mouseLeaveHideDelay = this._hideDelay; - instance - .afterHidden() - .pipe(takeUntil(this._destroyed)) - .subscribe(() => this._detach()); - this._setTooltipClass(this._tooltipClass); - this._updateTooltipMessage(); - instance.show(delay); + this._show(false, delay); } /** Hides the tooltip after the delay in ms, defaults to tooltip-delay-hide or 0ms if no input */ @@ -413,7 +389,7 @@ export abstract class _MatTooltipBase /** 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 */ @@ -692,7 +668,7 @@ export abstract class _MatTooltipBase // because it can prevent click events from firing on the element. this._setupPointerExitEventsIfNeeded(); clearTimeout(this._touchstartTimeout); - this._touchstartTimeout = setTimeout(() => this.show(), LONGPRESS_DELAY); + this._touchstartTimeout = setTimeout(() => this._show(true), LONGPRESS_DELAY); }, ]); } @@ -788,6 +764,41 @@ export abstract class _MatTooltipBase (style as any).webkitTapHighlightColor = 'transparent'; } } + + /** + * 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(this._tooltipComponent, this._viewContainerRef); + const instance = (this._tooltipInstance = overlayRef.attach(this._portal).instance); + instance._triggerElement = this._elementRef.nativeElement; + instance._mouseLeaveHideDelay = this._hideDelay; + instance + .afterHidden() + .pipe(takeUntil(this._destroyed)) + .subscribe(() => this._detach()); + this._setTooltipClass(this._tooltipClass); + this._updateTooltipMessage(); + instance!.show(delay, isUserInteraction); + } } /** @@ -838,7 +849,7 @@ export class MatTooltip extends _MatTooltipBase { } @Directive() -export abstract class _TooltipComponentBase implements OnDestroy { +export abstract class _TooltipComponentBase implements AfterViewInit, OnDestroy { /** Message to display in the tooltip */ message: string; @@ -860,32 +871,55 @@ export abstract class _TooltipComponentBase implements OnDestroy { /** Amount of milliseconds to delay the closing sequence. */ _mouseLeaveHideDelay: number; + /** Whether animations are currently disabled. */ + _animationsDisabled: boolean; + + /** Reference to the internal tooltip element. */ + abstract _tooltip: ElementRef; + /** 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(); - constructor(private _changeDetectorRef: ChangeDetectorRef) {} + /** Name of the show animation and the class that toggles it. */ + protected abstract readonly _showAnimation: string; + + /** Name of the hide animation and the class that toggles it. */ + protected abstract readonly _hideAnimation: string; + + constructor( + private _changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, + /** + * @deprecated _ngZone parameter to become required. + * @breaking-change 13.0.0 + */ + private _ngZone?: NgZone, + ) { + this._animationsDisabled = animationMode === 'NoopAnimations'; + } /** * 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 clearTimeout(this._hideTimeoutId); // 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 = undefined; - this._onShow(); - - // Mark for check so if any parent component has set the - // ChangeDetectionStrategy to OnPush it will be checked anyways - this._markForCheck(); }, delay); } @@ -898,12 +932,8 @@ export abstract class _TooltipComponentBase implements OnDestroy { clearTimeout(this._showTimeoutId); this._hideTimeoutId = setTimeout(() => { - this._visibility = 'hidden'; + this._toogleVisibility(false); this._hideTimeoutId = undefined; - - // Mark for check so if any parent component has set the - // ChangeDetectionStrategy to OnPush it will be checked anyways - this._markForCheck(); }, delay); } @@ -914,32 +944,34 @@ export abstract class _TooltipComponentBase implements OnDestroy { /** Whether the tooltip is being displayed. */ isVisible(): boolean { - return this._visibility === 'visible'; + return this._isVisible; + } + + ngAfterViewInit() { + if (!this._animationsDisabled) { + const bindEvents = () => { + this._tooltip.nativeElement.addEventListener('animationend', this._handleAnimationEnd); + this._tooltip.nativeElement.addEventListener('animationcancel', this._handleAnimationEnd); + }; + + // @breaking-change 11.0.0 Remove null check for `_ngZone`. + if (this._ngZone) { + this._ngZone.runOutsideAngular(bindEvents); + } else { + bindEvents(); + } + } } ngOnDestroy() { clearTimeout(this._showTimeoutId); clearTimeout(this._hideTimeoutId); + this._tooltip.nativeElement.removeEventListener('animationend', this._handleAnimationEnd); + this._tooltip.nativeElement.removeEventListener('animationcancel', this._handleAnimationEnd); this._onHide.complete(); this._triggerElement = null!; } - _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. @@ -972,6 +1004,48 @@ export abstract class _TooltipComponentBase implements OnDestroy { * in the mdc-tooltip, not here. */ protected _onShow(): void {} + + /** Event listener dispatched when an animation on the tooltip finishes. */ + private _handleAnimationEnd = ({animationName}: AnimationEvent) => { + if (animationName === this._showAnimation || animationName === this._hideAnimation) { + this._finalizeAnimation(animationName === this._showAnimation); + } + }; + + /** Handles the cleanup after an animation has finished. */ + private _finalizeAnimation(toVisible: boolean) { + if (!toVisible && !this.isVisible()) { + // @breaking-change 13.0.0 + if (this._ngZone) { + this._ngZone.run(() => this._onHide.next()); + } else { + this._onHide.next(); + } + } + + 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 = this._showAnimation; + const hideClass = this._hideAnimation; + classList.remove(isVisible ? hideClass : showClass); + classList.add(isVisible ? showClass : hideClass); + this._isVisible = isVisible; + + if (isVisible) { + this._onShow(); + } + + if (this._animationsDisabled) { + this._finalizeAnimation(isVisible); + } + } } /** @@ -984,11 +1058,10 @@ export abstract class _TooltipComponentBase implements OnDestroy { 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', + '[style.zoom]': 'isVisible() ? 1 : null', '(mouseleave)': '_handleMouseLeave($event)', 'aria-hidden': 'true', }, @@ -996,11 +1069,26 @@ export abstract class _TooltipComponentBase implements OnDestroy { export class TooltipComponent extends _TooltipComponentBase { /** Stream that emits whether the user has a handset-sized display. */ _isHandset: Observable = this._breakpointObserver.observe(Breakpoints.Handset); + _showAnimation = 'mat-tooltip-show'; + _hideAnimation = 'mat-tooltip-hide'; + + @ViewChild('tooltip', { + // Use a static query here since we interact directly with + // the DOM which can happen before `ngAfterViewInit`. + static: true, + }) + _tooltip: ElementRef; constructor( changeDetectorRef: ChangeDetectorRef, private _breakpointObserver: BreakpointObserver, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, + /** + * @deprecated _ngZone parameter to become required. + * @breaking-change 13.0.0 + */ + ngZone?: NgZone, ) { - super(changeDetectorRef); + super(changeDetectorRef, animationMode, ngZone); } } diff --git a/tools/public_api_guard/material/tooltip-testing.md b/tools/public_api_guard/material/tooltip-testing.md index 805d4f1efa9b..f40f0eefdca8 100644 --- a/tools/public_api_guard/material/tooltip-testing.md +++ b/tools/public_api_guard/material/tooltip-testing.md @@ -12,21 +12,33 @@ import { TestElement } from '@angular/cdk/testing'; // @public export class MatTooltipHarness extends _MatTooltipHarnessBase { + // (undocumented) + protected _hiddenClass: string; + // (undocumented) + protected _hideAnimationName: string; // (undocumented) static hostSelector: string; // (undocumented) protected _optionalPanel: AsyncFactoryFn; + // (undocumented) + protected _showAnimationName: string; static with(options?: TooltipHarnessFilters): HarnessPredicate; } // @public (undocumented) export abstract class _MatTooltipHarnessBase extends ComponentHarness { getTooltipText(): Promise; + // (undocumented) + protected abstract _hiddenClass: string; hide(): Promise; + // (undocumented) + protected abstract _hideAnimationName: string; isOpen(): Promise; // (undocumented) protected abstract _optionalPanel: AsyncFactoryFn; show(): Promise; + // (undocumented) + protected abstract _showAnimationName: string; } // @public diff --git a/tools/public_api_guard/material/tooltip.md b/tools/public_api_guard/material/tooltip.md index 9b9dbcabe573..618f7a5a972f 100644 --- a/tools/public_api_guard/material/tooltip.md +++ b/tools/public_api_guard/material/tooltip.md @@ -5,7 +5,6 @@ ```ts import { AfterViewInit } from '@angular/core'; -import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; import { AriaDescriber } from '@angular/cdk/a11y'; import { BooleanInput } from '@angular/cdk/coercion'; @@ -157,36 +156,46 @@ export const TOOLTIP_PANEL_CLASS = "mat-tooltip-panel"; // @public export class TooltipComponent extends _TooltipComponentBase { - constructor(changeDetectorRef: ChangeDetectorRef, _breakpointObserver: BreakpointObserver); + constructor(changeDetectorRef: ChangeDetectorRef, _breakpointObserver: BreakpointObserver, animationMode?: string, + ngZone?: NgZone); + // (undocumented) + _hideAnimation: string; _isHandset: Observable; // (undocumented) + _showAnimation: string; + // (undocumented) + _tooltip: ElementRef; + // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public (undocumented) -export abstract class _TooltipComponentBase implements OnDestroy { - constructor(_changeDetectorRef: ChangeDetectorRef); +export abstract class _TooltipComponentBase implements AfterViewInit, OnDestroy { + constructor(_changeDetectorRef: ChangeDetectorRef, animationMode?: string, + _ngZone?: NgZone | undefined); afterHidden(): Observable; - // (undocumented) - _animationDone(event: AnimationEvent_2): void; - // (undocumented) - _animationStart(): void; + _animationsDisabled: boolean; _handleBodyInteraction(): void; // (undocumented) _handleMouseLeave({ relatedTarget }: MouseEvent): void; hide(delay: number): void; + protected abstract readonly _hideAnimation: string; _hideTimeoutId: number | undefined; isVisible(): boolean; _markForCheck(): void; message: string; _mouseLeaveHideDelay: number; // (undocumented) + ngAfterViewInit(): void; + // (undocumented) ngOnDestroy(): void; protected _onShow(): void; - show(delay: number): void; + show(delay: number, isUserInteraction?: boolean): void; + protected abstract readonly _showAnimation: string; _showTimeoutId: number | undefined; + abstract _tooltip: ElementRef; tooltipClass: string | string[] | Set | { [key: string]: any; }; @@ -195,7 +204,7 @@ export abstract class _TooltipComponentBase implements OnDestroy { // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration<_TooltipComponentBase, never, never, {}, {}, never>; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration<_TooltipComponentBase, never>; + static ɵfac: i0.ɵɵFactoryDeclaration<_TooltipComponentBase, [null, { optional: true; }, null]>; } // @public