mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(tooltip): add tooltip animations (#1644)
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
<div class="demo-tooltip">
|
||||
<h1>Tooltip Demo</h1>
|
||||
|
||||
<p class="centered">
|
||||
<button
|
||||
<button #tooltip="mdTooltip"
|
||||
md-raised-button
|
||||
color="primary"
|
||||
md-tooltip="to see a tooltip"
|
||||
[md-tooltip]="message"
|
||||
[tooltip-position]="position">
|
||||
Mouse over this link
|
||||
Mouse over to see the tooltip
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<md-radio-group [(ngModel)]="position">
|
||||
<md-radio-button value="below">Below</md-radio-button>
|
||||
@@ -17,4 +19,20 @@
|
||||
<md-radio-button value="after">After</md-radio-button>
|
||||
</md-radio-group>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Message: </strong>
|
||||
<md-input type="text" [(ngModel)]="message"></md-input>
|
||||
</p>
|
||||
|
||||
<strong>Mouse over to</strong>
|
||||
<button md-raised-button color="primary" (mouseenter)="tooltip.show()">
|
||||
Show tooltip
|
||||
</button>
|
||||
<button md-raised-button color="primary" (mouseenter)="tooltip.hide(0)">
|
||||
Hide tooltip
|
||||
</button>
|
||||
<button md-raised-button color="primary" (mouseenter)="tooltip.toggle()">
|
||||
Toggle tooltip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -10,4 +10,5 @@ import {TooltipPosition} from '@angular/material';
|
||||
})
|
||||
export class TooltipDemo {
|
||||
position: TooltipPosition = 'below';
|
||||
message: string = 'Here is the tooltip';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="md-tooltip"
|
||||
[style.transform-origin]="_transformOrigin"
|
||||
[@state]="_visibility"
|
||||
(@state.done)="this._afterVisibilityAnimation($event)">
|
||||
{{message}}
|
||||
</div>
|
||||
@@ -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: `<button md-tooltip="some message" [tooltip-position]="position">Button</button>`
|
||||
template: `<button [md-tooltip]="message" [tooltip-position]="position">Button</button>`
|
||||
})
|
||||
class BasicTooltipDemo {
|
||||
position: TooltipPosition = 'below';
|
||||
message: string = initialTooltipMessage;
|
||||
}
|
||||
|
||||
+209
-122
@@ -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: `<div class="md-tooltip">{{message}}</div>`,
|
||||
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<any> = 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user