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);
+ }
+ }
}