From 52582f430537d03a63f996a6abab14085ee0dfa8 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Tue, 1 Nov 2016 16:18:16 -0700 Subject: [PATCH] feat(tooltip): add tooltip animations (#1644) --- src/demo-app/tooltip/tooltip-demo.html | 24 +- src/demo-app/tooltip/tooltip-demo.ts | 1 + src/lib/tooltip/tooltip.html | 6 + src/lib/tooltip/tooltip.spec.ts | 92 ++++++- src/lib/tooltip/tooltip.ts | 331 ++++++++++++++++--------- 5 files changed, 316 insertions(+), 138 deletions(-) diff --git a/src/demo-app/tooltip/tooltip-demo.html b/src/demo-app/tooltip/tooltip-demo.html index 9fa89c90357..c96a266f0be 100644 --- a/src/demo-app/tooltip/tooltip-demo.html +++ b/src/demo-app/tooltip/tooltip-demo.html @@ -1,14 +1,16 @@

Tooltip Demo

+

-

+

Below @@ -17,4 +19,20 @@ After

+ +

+ Message: + +

+ + Mouse over to + + +
diff --git a/src/demo-app/tooltip/tooltip-demo.ts b/src/demo-app/tooltip/tooltip-demo.ts index 48b01283939..5d0cc2506ac 100644 --- a/src/demo-app/tooltip/tooltip-demo.ts +++ b/src/demo-app/tooltip/tooltip-demo.ts @@ -10,4 +10,5 @@ import {TooltipPosition} from '@angular/material'; }) export class TooltipDemo { position: TooltipPosition = 'below'; + message: string = 'Here is the tooltip'; } diff --git a/src/lib/tooltip/tooltip.html b/src/lib/tooltip/tooltip.html index e69de29bb2d..0e43808a15c 100644 --- a/src/lib/tooltip/tooltip.html +++ b/src/lib/tooltip/tooltip.html @@ -0,0 +1,6 @@ +
+ {{message}} +
\ No newline at end of file diff --git a/src/lib/tooltip/tooltip.spec.ts b/src/lib/tooltip/tooltip.spec.ts index 643711d0f5e..d1d8b4fe175 100644 --- a/src/lib/tooltip/tooltip.spec.ts +++ b/src/lib/tooltip/tooltip.spec.ts @@ -1,14 +1,18 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import { + async, ComponentFixture, TestBed, tick, fakeAsync, + flushMicrotasks +} from '@angular/core/testing'; import {Component, DebugElement} from '@angular/core'; import {By} from '@angular/platform-browser'; -import {TooltipPosition, MdTooltip} from './tooltip'; +import {TooltipPosition, MdTooltip, TOOLTIP_HIDE_DELAY, MdTooltipModule} from './tooltip'; import {OverlayContainer} from '../core'; -import {MdTooltipModule} from './tooltip'; +const initialTooltipMessage = 'initial tooltip message'; describe('MdTooltip', () => { let overlayContainerElement: HTMLElement; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [MdTooltipModule.forRoot()], @@ -38,22 +42,83 @@ describe('MdTooltip', () => { tooltipDirective = buttonDebugElement.injector.get(MdTooltip); }); - it('should show/hide on mouse enter/leave', () => { - expect(tooltipDirective.visible).toBeFalsy(); + it('should show and hide the tooltip', fakeAsync(() => { + expect(tooltipDirective._tooltipInstance).toBeUndefined(); - tooltipDirective._handleMouseEnter(null); - expect(tooltipDirective.visible).toBeTruthy(); + tooltipDirective.show(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); fixture.detectChanges(); - expect(overlayContainerElement.textContent).toBe('some message'); + expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); - tooltipDirective._handleMouseLeave(null); - expect(overlayContainerElement.textContent).toBe(''); + // After hide called, a timeout delay is created that will to hide the tooltip. + tooltipDirective.hide(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + // After the tooltip delay elapses, expect that the tooltip is not visible. + tick(TOOLTIP_HIDE_DELAY); + fixture.detectChanges(); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + + // On animation complete, should expect that the tooltip has been detached. + flushMicrotasks(); + expect(tooltipDirective._tooltipInstance).toBeNull(); + })); + + it('should not follow through with hide if show is called after', fakeAsync(() => { + tooltipDirective.show(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + // After hide called, a timeout delay is created that will to hide the tooltip. + tooltipDirective.hide(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + // Before delay time has passed, call show which should cancel intent to hide tooltip. + tooltipDirective.show(); + tick(TOOLTIP_HIDE_DELAY); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + })); + + it('should remove the tooltip when changing position', () => { + const initialPosition: TooltipPosition = 'below'; + const changedPosition: TooltipPosition = 'above'; + + expect(tooltipDirective._tooltipInstance).toBeUndefined(); + + tooltipDirective.position = initialPosition; + tooltipDirective.show(); + expect(tooltipDirective._tooltipInstance).toBeDefined(); + + // Same position value should not remove the tooltip + tooltipDirective.position = initialPosition; + expect(tooltipDirective._tooltipInstance).toBeDefined(); + + // Different position value should destroy the tooltip + tooltipDirective.position = changedPosition; + expect(tooltipDirective._tooltipInstance).toBeNull(); + expect(tooltipDirective._overlayRef).toBeNull(); + }); + + it('should be able to modify the tooltip message', () => { + expect(tooltipDirective._tooltipInstance).toBeUndefined(); + + tooltipDirective.show(); + expect(tooltipDirective._tooltipInstance._visibility).toBe('visible'); + + fixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); + + const newMessage = 'new tooltip message'; + tooltipDirective.message = newMessage; + + fixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain(newMessage); }); it('should be removed after parent destroyed', () => { - tooltipDirective._handleMouseEnter(null); - expect(tooltipDirective.visible).toBeTruthy(); + tooltipDirective.show(); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + fixture.destroy(); expect(overlayContainerElement.childNodes.length).toBe(0); expect(overlayContainerElement.textContent).toBe(''); @@ -63,8 +128,9 @@ describe('MdTooltip', () => { @Component({ selector: 'app', - template: `` + template: `` }) class BasicTooltipDemo { position: TooltipPosition = 'below'; + message: string = initialTooltipMessage; } diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts index 6916c761cc7..8dbde939c30 100644 --- a/src/lib/tooltip/tooltip.ts +++ b/src/lib/tooltip/tooltip.ts @@ -1,12 +1,18 @@ import { - NgModule, - ModuleWithProviders, - Component, - Directive, - Input, - ElementRef, - ViewContainerRef, - ChangeDetectorRef + NgModule, + ModuleWithProviders, + Component, + Directive, + Input, + ElementRef, + ViewContainerRef, + style, + trigger, + state, + transition, + animate, + AnimationTransitionEvent, + NgZone, } from '@angular/core'; import { Overlay, @@ -18,29 +24,47 @@ import { OriginConnectionPosition, OVERLAY_PROVIDERS, } from '../core'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; export type TooltipPosition = 'before' | 'after' | 'above' | 'below'; +/** Time in ms to delay before changing the tooltip visibility to hidden */ +export const TOOLTIP_HIDE_DELAY = 1500; + +/** + * Directive that attaches a material design tooltip to the host element. Animates the showing and + * hiding of a tooltip provided position (defaults to below the element). + * + * https://material.google.com/components/tooltips.html + */ @Directive({ selector: '[md-tooltip]', host: { - '(mouseenter)': '_handleMouseEnter($event)', - '(mouseleave)': '_handleMouseLeave($event)', - } + '(mouseenter)': 'show()', + '(mouseleave)': 'hide()', + }, + exportAs: 'mdTooltip', }) export class MdTooltip { - visible: boolean = false; + _overlayRef: OverlayRef; + _tooltipInstance: TooltipComponent; /** Allows the user to define the position of the tooltip relative to the parent element */ private _position: TooltipPosition = 'below'; @Input('tooltip-position') get position(): TooltipPosition { return this._position; } + set position(value: TooltipPosition) { if (value !== this._position) { this._position = value; - this._createOverlay(); - this._updatePosition(); + + // TODO(andrewjs): When the overlay's position can be dynamically changed, do not destroy + // the tooltip. + if (this._tooltipInstance) { + this._disposeTooltip(); + } } } @@ -51,152 +75,215 @@ export class MdTooltip { } set message(value: string) { this._message = value; - this._updatePosition(); + if (this._tooltipInstance) { + this._setTooltipMessage(this._message); + } } - private _overlayRef: OverlayRef; - constructor(private _overlay: Overlay, private _elementRef: ElementRef, - private _viewContainerRef: ViewContainerRef, - private _changeDetectionRef: ChangeDetectorRef) {} + private _viewContainerRef: ViewContainerRef, private _ngZone: NgZone) {} - /** - * Create overlay on init - * TODO: internal - */ - ngOnInit() { - this._createOverlay(); + /** Dispose the tooltip when destroyed */ + ngOnDestroy() { + if (this._tooltipInstance) { + this._disposeTooltip(); + } } - /** - * Remove overlay after tooltip's parent has been destroyed - */ - ngOnDestroy() { - this.visible = false; - this._overlayRef.dispose(); - this._overlayRef = null; + /** Shows the tooltip */ + show(): void { + if (!this._tooltipInstance) { + this._createTooltip(); + } + + this._setTooltipMessage(this._message); + this._tooltipInstance.show(this._position); } /** * Create the overlay config and position strategy + * Hides the tooltip after the provided delay in ms. Defaults the delay to the material design + * prescribed delay time */ - private _createOverlay() { - if (this._overlayRef) { - if (this.visible) { - // if visible, hide before destroying - this.hide(); - this._createOverlay(); - } else { - // if not visible, dispose and recreate - this._overlayRef.dispose(); - this._overlayRef = null; - this._createOverlay(); - } - } else { - let origin = this._getOrigin(); - let position = this._getOverlayPosition(); - let strategy = this._overlay.position().connectedTo(this._elementRef, origin, position); - let config = new OverlayState(); - config.positionStrategy = strategy; - - this._overlayRef = this._overlay.create(config); + hide(delay: number = TOOLTIP_HIDE_DELAY): void { + if (this._tooltipInstance) { + this._tooltipInstance.hide(delay); } } - /** - * Returns the origin position based on the user's position preference - */ + /** Shows/hides the tooltip */ + toggle(): void { + this._isTooltipVisible() ? this.hide() : this.show(); + } + + /** Returns true if the tooltip is currently visible to the user */ + _isTooltipVisible(): boolean { + return this._tooltipInstance && this._tooltipInstance.isVisible(); + } + + /** Create the tooltip to display */ + private _createTooltip(): void { + this._createOverlay(); + let portal = new ComponentPortal(TooltipComponent, this._viewContainerRef); + this._tooltipInstance = this._overlayRef.attach(portal).instance; + + // Dispose the overlay when finished the shown tooltip. + this._tooltipInstance.afterHidden().subscribe(() => { + this._disposeTooltip(); + }); + } + + /** Create the overlay config and position strategy */ + private _createOverlay(): void { + let origin = this._getOrigin(); + let position = this._getOverlayPosition(); + let strategy = this._overlay.position().connectedTo(this._elementRef, origin, position); + let config = new OverlayState(); + config.positionStrategy = strategy; + + this._overlayRef = this._overlay.create(config); + } + + /** Disposes the current tooltip and the overlay it is attached to */ + private _disposeTooltip(): void { + this._overlayRef.dispose(); + this._overlayRef = null; + this._tooltipInstance = null; + } + + /** Returns the origin position based on the user's position preference */ private _getOrigin(): OriginConnectionPosition { switch (this.position) { - case 'before': return { originX: 'start', originY: 'center' }; - case 'after': return { originX: 'end', originY: 'center' }; + case 'before': return { originX: 'start', originY: 'center' }; + case 'after': return { originX: 'end', originY: 'center' }; case 'above': return { originX: 'center', originY: 'top' }; case 'below': return { originX: 'center', originY: 'bottom' }; } } - /** - * Returns the overlay position based on the user's preference - */ + /** Returns the overlay position based on the user's preference */ private _getOverlayPosition(): OverlayConnectionPosition { switch (this.position) { - case 'before': return { overlayX: 'end', overlayY: 'center' }; - case 'after': return { overlayX: 'start', overlayY: 'center' }; + case 'before': return { overlayX: 'end', overlayY: 'center' }; + case 'after': return { overlayX: 'start', overlayY: 'center' }; case 'above': return { overlayX: 'center', overlayY: 'bottom' }; case 'below': return { overlayX: 'center', overlayY: 'top' }; } } - /** - * Shows the tooltip on mouse enter - * @param event - */ - _handleMouseEnter(event: MouseEvent) { - this.show(); - } - - /** - * Hides the tooltip on mouse leave - * @param event - */ - _handleMouseLeave(event: MouseEvent) { - this.hide(); - } - - /** - * Shows the tooltip and returns a promise that will resolve when the tooltip is visible - */ - show(): void { - if (!this.visible && this._overlayRef && !this._overlayRef.hasAttached()) { - this.visible = true; - - let portal = new ComponentPortal(TooltipComponent, this._viewContainerRef); - let tooltipRef = this._overlayRef.attach(portal); - tooltipRef.instance.message = this.message; - this._updatePosition(); - } - } - - /** - * Hides the tooltip and returns a promise that will resolve when the tooltip is hidden - */ - hide(): void { - if (this.visible && this._overlayRef && this._overlayRef.hasAttached()) { - this.visible = false; - this._overlayRef.detach(); - } - } - - /** - * Shows/hides the tooltip and returns a promise that will resolve when it is done - */ - toggle(): void { - if (this.visible) { - this.hide(); - } else { - this.show(); - } - } - - /** - * Updates the tooltip's position - */ - private _updatePosition() { - if (this._overlayRef) { - this._changeDetectionRef.detectChanges(); - this._overlayRef.updatePosition(); - } + /** Updates the tooltip message and repositions the overlay according to the new message length */ + private _setTooltipMessage(message: string) { + // Must wait for the message to be painted to the tooltip so that the overlay can properly + // calculate the correct positioning based on the size of the text. + this._tooltipInstance.message = message; + this._ngZone.onMicrotaskEmpty.first().subscribe(() => { + if (this._tooltipInstance) { + this._overlayRef.updatePosition(); + } + }); } } +export type TooltipVisibility = 'visible' | 'hidden'; + @Component({ moduleId: module.id, selector: 'md-tooltip-component', - template: `
{{message}}
`, + templateUrl: 'tooltip.html', styleUrls: ['tooltip.css'], + animations: [ + trigger('state', [ + state('void', style({transform: 'scale(0)'})), + state('visible', style({transform: 'scale(1)'})), + state('hidden', style({transform: 'scale(0)'})), + transition('* => visible', animate('150ms cubic-bezier(0.0, 0.0, 0.2, 1)')), + transition('* => hidden', animate('150ms cubic-bezier(0.4, 0.0, 1, 1)')), + ]) + ], + host: { + '(body:click)': 'this._handleBodyInteraction()' + } }) export class TooltipComponent { + /** Message to display in the tooltip */ message: string; + + /** The timeout ID of any current timer set to hide the tooltip */ + _hideTimeoutId: number; + + /** Property watched by the animation framework to show or hide the tooltip */ + _visibility: TooltipVisibility; + + /** Whether interactions on the page should close the tooltip */ + _closeOnInteraction: boolean = false; + + /** The transform origin used in the animation for showing and hiding the tooltip */ + _transformOrigin: string = 'bottom'; + + /** Subject for notifying that the tooltip has been hidden from the view */ + private _onHide: Subject = new Subject(); + + /** Shows the tooltip with an animation originating from the provided origin */ + show(position: TooltipPosition): void { + this._closeOnInteraction = false; + this._visibility = 'visible'; + this._setTransformOrigin(position); + + // Cancel the delayed hide if it is scheduled + if (this._hideTimeoutId) { + clearTimeout(this._hideTimeoutId); + } + + // If this was set to true immediately, then the body click would trigger interaction and + // close the tooltip right after it was displayed. + setTimeout(() => { this._closeOnInteraction = true; }, 0); + } + + /** Begins the animation to hide the tooltip after the provided delay in ms */ + hide(delay: number): void { + this._hideTimeoutId = setTimeout(() => { + this._visibility = 'hidden'; + this._closeOnInteraction = false; + }, delay); + } + + /** Returns an observable that notifies when the tooltip has been hidden from view */ + afterHidden(): Observable { + return this._onHide.asObservable(); + } + + /** Whether the tooltip is being displayed */ + isVisible(): boolean { + return this._visibility === 'visible'; + } + + /** Sets the tooltip transform origin according to the tooltip position */ + _setTransformOrigin(value: TooltipPosition) { + switch (value) { + case 'before': this._transformOrigin = 'right'; break; + case 'after': this._transformOrigin = 'left'; break; + case 'above': this._transformOrigin = 'bottom'; break; + case 'below': this._transformOrigin = 'top'; break; + } + } + + _afterVisibilityAnimation(e: AnimationTransitionEvent): void { + if (e.toState === 'hidden' && !this.isVisible()) { + this._onHide.next(); + + } + } + + /** + * Interactions on the HTML body should close the tooltip immediately as defined in the + * material design spec. + * https://material.google.com/components/tooltips.html#tooltips-interaction + */ + _handleBodyInteraction(): void { + if (this._closeOnInteraction) { + this.hide(0); + } + } }