From b439b57f41c230c76755c04d5d994050d3b0a77b Mon Sep 17 00:00:00 2001 From: Robert Messerle Date: Fri, 27 May 2016 11:01:12 -0700 Subject: [PATCH] feat(tooltip): initial tooltip implementation --- src/components/tooltip/README.md | 24 ++++ src/components/tooltip/tooltip.html | 0 src/components/tooltip/tooltip.scss | 22 +++ src/components/tooltip/tooltip.spec.ts | 101 ++++++++++++++ src/components/tooltip/tooltip.ts | 183 +++++++++++++++++++++++++ src/core/overlay/overlay-ref.ts | 4 +- src/demo-app/demo-app/demo-app.html | 5 +- src/demo-app/demo-app/routes.ts | 3 +- src/demo-app/system-config.ts | 3 +- src/demo-app/tooltip/tooltip-demo.html | 20 +++ src/demo-app/tooltip/tooltip-demo.scss | 8 ++ src/demo-app/tooltip/tooltip-demo.ts | 19 +++ src/e2e-app/system-config.ts | 3 +- 13 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 src/components/tooltip/README.md create mode 100644 src/components/tooltip/tooltip.html create mode 100644 src/components/tooltip/tooltip.scss create mode 100644 src/components/tooltip/tooltip.spec.ts create mode 100644 src/components/tooltip/tooltip.ts create mode 100644 src/demo-app/tooltip/tooltip-demo.html create mode 100644 src/demo-app/tooltip/tooltip-demo.scss create mode 100644 src/demo-app/tooltip/tooltip-demo.ts diff --git a/src/components/tooltip/README.md b/src/components/tooltip/README.md new file mode 100644 index 000000000000..a13bd047a588 --- /dev/null +++ b/src/components/tooltip/README.md @@ -0,0 +1,24 @@ +# MdTooltip +Tooltip allows the user to specify text to be displayed when the mouse hover over an element. + +### Examples +A button with a tooltip +```html + +``` + +## `[md-tooltip]` +### Properties + +| Name | Type | Description | +| --- | --- | --- | +| `md-tooltip` | `string` | The message to be displayed. | +| `tooltip-position` | `"above"|"below"|"before"|"after"` | The position of the tooltip. | + +### Methods + +| Name | Description | +| --- | --- | --- | +| `show` | Displays the tooltip. | +| `hide` | Removes the tooltip. | +| `toggle` | Displays or hides the tooltip. | diff --git a/src/components/tooltip/tooltip.html b/src/components/tooltip/tooltip.html new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/components/tooltip/tooltip.scss b/src/components/tooltip/tooltip.scss new file mode 100644 index 000000000000..8b32906046e4 --- /dev/null +++ b/src/components/tooltip/tooltip.scss @@ -0,0 +1,22 @@ +@import 'variables'; +@import 'theme-functions'; +@import 'palette'; + +$md-tooltip-height: 22px; +$md-tooltip-margin: 14px; +$md-tooltip-padding: 8px; + +:host { + pointer-events: none; +} +.md-tooltip { + background: md-color($md-grey, 700, 0.9); + color: white; + padding: 0 $md-tooltip-padding; + border-radius: 2px; + font-family: $md-font-family; + font-size: 10px; + margin: $md-tooltip-margin; + height: $md-tooltip-height; + line-height: $md-tooltip-height; +} diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts new file mode 100644 index 000000000000..9a06708ae188 --- /dev/null +++ b/src/components/tooltip/tooltip.spec.ts @@ -0,0 +1,101 @@ +import { + it, + describe, + expect, + beforeEach, + inject, + async, + beforeEachProviders, +} from '@angular/core/testing'; +import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing'; +import {Component, provide, DebugElement} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {MD_TOOLTIP_DIRECTIVES, TooltipPosition, MdTooltip} from + '@angular2-material/tooltip/tooltip'; +import {OVERLAY_PROVIDERS, OVERLAY_CONTAINER_TOKEN} from '@angular2-material/core/overlay/overlay'; + +describe('MdTooltip', () => { + let builder: TestComponentBuilder; + let overlayContainerElement: HTMLElement; + + beforeEachProviders(() => [ + OVERLAY_PROVIDERS, + provide(OVERLAY_CONTAINER_TOKEN, { + useFactory: () => { + overlayContainerElement = document.createElement('div'); + return overlayContainerElement; + } + }) + ]); + + beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { + builder = tcb; + })); + + describe('basic usage', () => { + let fixture: ComponentFixture; + let buttonDebugElement: DebugElement; + let buttonElement: HTMLButtonElement; + let tooltipDirective: MdTooltip; + + beforeEach(async(() => { + builder.createAsync(BasicTooltipDemo).then(f => { + fixture = f; + fixture.detectChanges(); + buttonDebugElement = fixture.debugElement.query(By.css('button')); + buttonElement = buttonDebugElement.nativeElement; + tooltipDirective = buttonDebugElement.injector.get(MdTooltip); + }); + })); + + it('should show/hide on mouse enter/leave', async(() => { + expect(tooltipDirective.visible).toBeFalsy(); + + tooltipDirective._handleMouseEnter(null); + expect(tooltipDirective.visible).toBeTruthy(); + + fixture.detectChanges(); + whenStable([ + () => { + expect(overlayContainerElement.textContent).toBe('some message'); + tooltipDirective._handleMouseLeave(null); + }, + () => { + expect(overlayContainerElement.textContent).toBe(''); + } + ]); + })); + + /** + * Utility function to make it easier to use multiple `whenStable` checks. + * Accepts an array of callbacks, each to wait for stability before running. + * TODO: Remove the `setTimeout()` when a viable alternative is available + * @param callbacks + */ + function whenStable(callbacks: Array) { + if (callbacks.length) { + fixture.detectChanges(); + fixture.whenStable().then(() => { + // TODO(jelbourn): figure out why the test zone is "stable" when there are still pending + // tasks, such that we have to use `setTimeout` to run the second round of change + // detection. Two rounds of change detection are necessary: one to *create* the tooltip, + // and another to cause the lifecycle events of the tooltip to run and load the tooltip + // content. + setTimeout(() => { + callbacks[0](); + whenStable(callbacks.slice(1)); + }, 50); + }); + } + } + }); +}); + +@Component({ + selector: 'app', + directives: [MD_TOOLTIP_DIRECTIVES], + template: `` +}) +class BasicTooltipDemo { + position: TooltipPosition = 'below'; +} diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts new file mode 100644 index 000000000000..99d168dcf170 --- /dev/null +++ b/src/components/tooltip/tooltip.ts @@ -0,0 +1,183 @@ +import {Component, ComponentRef, Directive, Input, ElementRef, ViewContainerRef, + ChangeDetectorRef} from '@angular/core'; +import {Overlay} from '@angular2-material/core/overlay/overlay'; +import {OverlayState} from '@angular2-material/core/overlay/overlay-state'; +import {OverlayRef} from '@angular2-material/core/overlay/overlay-ref'; +import {ComponentPortal} from '@angular2-material/core/portal/portal'; +import {OverlayConnectionPosition, OriginConnectionPosition} from + '@angular2-material/core/overlay/position/connected-position'; + +export type TooltipPosition = 'before' | 'after' | 'above' | 'below'; + +@Directive({ + selector: '[md-tooltip]', + host: { + '(mouseenter)': '_handleMouseEnter($event)', + '(mouseleave)': '_handleMouseLeave($event)', + } +}) +export class MdTooltip { + visible: boolean = false; + + /** 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(); + } + } + + /** The message to be displayed in the tooltip */ + private _message: string; + @Input('md-tooltip') get message() { + return this._message; + } + set message(value: string) { + this._message = value; + this._updatePosition(); + } + + private _overlayRef: OverlayRef; + + constructor(private _overlay: Overlay, private _elementRef: ElementRef, + private _viewContainerRef: ViewContainerRef, + private _changeDetectionRef: ChangeDetectorRef) {} + + /** + * Create overlay on init + * TODO: @internal + */ + ngOnInit() { + this._createOverlay(); + } + + /** + * Create the overlay config and position strategy + */ + private _createOverlay() { + if (this._overlayRef) { + if (this.visible) { + // if visible, hide before destroying + this.hide().then(() => 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._overlay.create(config).then(ref => { + this._overlayRef = ref; + }); + } + } + + /** + * 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 'above': return { originX: 'center', originY: 'top' }; + case 'below': return { originX: 'center', originY: 'bottom' }; + } + } + + /** + * 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 '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(): Promise { + if (!this.visible && this._overlayRef && !this._overlayRef.hasAttached()) { + this.visible = true; + let promise = this._overlayRef.attach(new ComponentPortal(TooltipComponent, + this._viewContainerRef)); + promise.then((ref: ComponentRef) => { + ref.instance.message = this.message; + this._updatePosition(); + }); + return promise; + } + } + + /** + * Hides the tooltip and returns a promise that will resolve when the tooltip is hidden + */ + hide(): Promise { + if (this.visible && this._overlayRef && this._overlayRef.hasAttached()) { + this.visible = false; + return this._overlayRef.detach(); + } + } + + /** + * Shows/hides the tooltip and returns a promise that will resolve when it is done + */ + toggle(): Promise { + if (this.visible) { + return this.hide(); + } else { + return this.show(); + } + } + + /** + * Updates the tooltip's position + */ + private _updatePosition() { + if (this._overlayRef) { + this._changeDetectionRef.detectChanges(); + this._overlayRef.updatePosition(); + } + } +} + +@Component({ + moduleId: module.id, + selector: 'md-tooltip-component', + template: `
{{message}}
`, + styleUrls: ['tooltip.css'], +}) +class TooltipComponent { + message: string; +} + +export const MD_TOOLTIP_DIRECTIVES = [MdTooltip]; diff --git a/src/core/overlay/overlay-ref.ts b/src/core/overlay/overlay-ref.ts index ab402e8e54f7..5db00dbe0a77 100644 --- a/src/core/overlay/overlay-ref.ts +++ b/src/core/overlay/overlay-ref.ts @@ -17,7 +17,7 @@ export class OverlayRef implements PortalHost { // Don't chain the .then() call in the return because we want the result of portalHost.attach // to be returned from this method. attachPromise.then(() => { - this._updatePosition(); + this.updatePosition(); }); return attachPromise; @@ -41,7 +41,7 @@ export class OverlayRef implements PortalHost { } /** Updates the position of the overlay based on the position strategy. */ - private _updatePosition() { + updatePosition() { if (this._state.positionStrategy) { this._state.positionStrategy.apply(this._pane); } diff --git a/src/demo-app/demo-app/demo-app.html b/src/demo-app/demo-app/demo-app.html index b0fbf16f874a..451170190e09 100644 --- a/src/demo-app/demo-app/demo-app.html +++ b/src/demo-app/demo-app/demo-app.html @@ -15,14 +15,15 @@ Live Announcer Overlay Portal - Progress Circle Progress Bar + Progress Circle Radio Sidenav Slider Slide Toggle - Toolbar Tabs + Toolbar + Tooltip
Baseline diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 128ce23232a6..b75b543da341 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -23,7 +23,7 @@ import {RadioDemo} from '../radio/radio-demo'; import {CardDemo} from '../card/card-demo'; import {MenuDemo} from '../menu/menu-demo'; import {DialogDemo} from '../dialog/dialog-demo'; - +import {TooltipDemo} from '../tooltip/tooltip-demo'; export const routes: RouterConfig = [ @@ -51,6 +51,7 @@ export const routes: RouterConfig = [ {path: 'button-toggle', component: ButtonToggleDemo}, {path: 'baseline', component: BaselineDemo}, {path: 'dialog', component: DialogDemo}, + {path: 'tooltip', component: TooltipDemo}, ]; export const DEMO_APP_ROUTE_PROVIDER = provideRouter(routes); diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts index a5b3dad5f39e..ef20dee089dc 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -20,7 +20,8 @@ const components = [ 'slide-toggle', 'button-toggle', 'tabs', - 'toolbar' + 'toolbar', + 'tooltip', ]; /** Map relative paths to URLs. */ diff --git a/src/demo-app/tooltip/tooltip-demo.html b/src/demo-app/tooltip/tooltip-demo.html new file mode 100644 index 000000000000..9fa89c903574 --- /dev/null +++ b/src/demo-app/tooltip/tooltip-demo.html @@ -0,0 +1,20 @@ +
+

Tooltip Demo

+

+ +

+

+ + Below + Above + Before + After + +

+
diff --git a/src/demo-app/tooltip/tooltip-demo.scss b/src/demo-app/tooltip/tooltip-demo.scss new file mode 100644 index 000000000000..39c9602a9e72 --- /dev/null +++ b/src/demo-app/tooltip/tooltip-demo.scss @@ -0,0 +1,8 @@ +.demo-tooltip { + .centered { + text-align: center; + } + md-radio-button { + display: block; + } +} diff --git a/src/demo-app/tooltip/tooltip-demo.ts b/src/demo-app/tooltip/tooltip-demo.ts new file mode 100644 index 000000000000..9ce01008c41f --- /dev/null +++ b/src/demo-app/tooltip/tooltip-demo.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import {MD_TOOLTIP_DIRECTIVES, TooltipPosition} from '@angular2-material/tooltip/tooltip'; +import {OVERLAY_PROVIDERS} from '@angular2-material/core/overlay/overlay'; +import {MD_RADIO_DIRECTIVES} from '@angular2-material/radio/radio'; +import {MdUniqueSelectionDispatcher} from + '@angular2-material/core/coordination/unique-selection-dispatcher'; +import {MD_BUTTON_DIRECTIVES} from '@angular2-material/button/button'; + +@Component({ + moduleId: module.id, + selector: 'tooltip-demo', + templateUrl: 'tooltip-demo.html', + styleUrls: ['tooltip-demo.css'], + directives: [MD_TOOLTIP_DIRECTIVES, MD_RADIO_DIRECTIVES, MD_BUTTON_DIRECTIVES], + providers: [OVERLAY_PROVIDERS, MdUniqueSelectionDispatcher], +}) +export class TooltipDemo { + position: TooltipPosition = 'below'; +} diff --git a/src/e2e-app/system-config.ts b/src/e2e-app/system-config.ts index 67949181b47f..691eac4a2195 100644 --- a/src/e2e-app/system-config.ts +++ b/src/e2e-app/system-config.ts @@ -19,7 +19,8 @@ const components = [ 'sidenav', 'slide-toggle', 'tabs', - 'toolbar' + 'toolbar', + 'tooltip', ];