Skip to content

Commit f5e2a81

Browse files
feat(tooltip): initial tooltip implementation (#799)
1 parent 9a32489 commit f5e2a81

File tree

13 files changed

+388
-7
lines changed

13 files changed

+388
-7
lines changed

src/components/tooltip/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# MdTooltip
2+
Tooltip allows the user to specify text to be displayed when the mouse hover over an element.
3+
4+
### Examples
5+
A button with a tooltip
6+
```html
7+
<button md-tooltip="some message" tooltip-position="below">Button</button>
8+
```
9+
10+
## `[md-tooltip]`
11+
### Properties
12+
13+
| Name | Type | Description |
14+
| --- | --- | --- |
15+
| `md-tooltip` | `string` | The message to be displayed. |
16+
| `tooltip-position` | `"above"|"below"|"before"|"after"` | The position of the tooltip. |
17+
18+
### Methods
19+
20+
| Name | Description |
21+
| --- | --- | --- |
22+
| `show` | Displays the tooltip. |
23+
| `hide` | Removes the tooltip. |
24+
| `toggle` | Displays or hides the tooltip. |

src/components/tooltip/tooltip.html

Whitespace-only changes.

src/components/tooltip/tooltip.scss

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@import 'variables';
2+
@import 'theme-functions';
3+
@import 'palette';
4+
5+
$md-tooltip-height: 22px;
6+
$md-tooltip-margin: 14px;
7+
$md-tooltip-padding: 8px;
8+
9+
:host {
10+
pointer-events: none;
11+
}
12+
.md-tooltip {
13+
background: md-color($md-grey, 700, 0.9);
14+
color: white;
15+
padding: 0 $md-tooltip-padding;
16+
border-radius: 2px;
17+
font-family: $md-font-family;
18+
font-size: 10px;
19+
margin: $md-tooltip-margin;
20+
height: $md-tooltip-height;
21+
line-height: $md-tooltip-height;
22+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
it,
3+
describe,
4+
expect,
5+
beforeEach,
6+
inject,
7+
async,
8+
beforeEachProviders,
9+
} from '@angular/core/testing';
10+
import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing';
11+
import {Component, provide, DebugElement} from '@angular/core';
12+
import {By} from '@angular/platform-browser';
13+
import {MD_TOOLTIP_DIRECTIVES, TooltipPosition, MdTooltip} from
14+
'@angular2-material/tooltip/tooltip';
15+
import {OVERLAY_PROVIDERS, OVERLAY_CONTAINER_TOKEN} from '@angular2-material/core/overlay/overlay';
16+
17+
describe('MdTooltip', () => {
18+
let builder: TestComponentBuilder;
19+
let overlayContainerElement: HTMLElement;
20+
21+
beforeEachProviders(() => [
22+
OVERLAY_PROVIDERS,
23+
provide(OVERLAY_CONTAINER_TOKEN, {
24+
useFactory: () => {
25+
overlayContainerElement = document.createElement('div');
26+
return overlayContainerElement;
27+
}
28+
})
29+
]);
30+
31+
beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
32+
builder = tcb;
33+
}));
34+
35+
describe('basic usage', () => {
36+
let fixture: ComponentFixture<BasicTooltipDemo>;
37+
let buttonDebugElement: DebugElement;
38+
let buttonElement: HTMLButtonElement;
39+
let tooltipDirective: MdTooltip;
40+
41+
beforeEach(async(() => {
42+
builder.createAsync(BasicTooltipDemo).then(f => {
43+
fixture = f;
44+
fixture.detectChanges();
45+
buttonDebugElement = fixture.debugElement.query(By.css('button'));
46+
buttonElement = <HTMLButtonElement> buttonDebugElement.nativeElement;
47+
tooltipDirective = buttonDebugElement.injector.get(MdTooltip);
48+
});
49+
}));
50+
51+
it('should show/hide on mouse enter/leave', async(() => {
52+
expect(tooltipDirective.visible).toBeFalsy();
53+
54+
tooltipDirective._handleMouseEnter(null);
55+
expect(tooltipDirective.visible).toBeTruthy();
56+
57+
fixture.detectChanges();
58+
whenStable([
59+
() => {
60+
expect(overlayContainerElement.textContent).toBe('some message');
61+
tooltipDirective._handleMouseLeave(null);
62+
},
63+
() => {
64+
expect(overlayContainerElement.textContent).toBe('');
65+
}
66+
]);
67+
}));
68+
69+
/**
70+
* Utility function to make it easier to use multiple `whenStable` checks.
71+
* Accepts an array of callbacks, each to wait for stability before running.
72+
* TODO: Remove the `setTimeout()` when a viable alternative is available
73+
* @param callbacks
74+
*/
75+
function whenStable(callbacks: Array<Function>) {
76+
if (callbacks.length) {
77+
fixture.detectChanges();
78+
fixture.whenStable().then(() => {
79+
// TODO(jelbourn): figure out why the test zone is "stable" when there are still pending
80+
// tasks, such that we have to use `setTimeout` to run the second round of change
81+
// detection. Two rounds of change detection are necessary: one to *create* the tooltip,
82+
// and another to cause the lifecycle events of the tooltip to run and load the tooltip
83+
// content.
84+
setTimeout(() => {
85+
callbacks[0]();
86+
whenStable(callbacks.slice(1));
87+
}, 50);
88+
});
89+
}
90+
}
91+
});
92+
});
93+
94+
@Component({
95+
selector: 'app',
96+
directives: [MD_TOOLTIP_DIRECTIVES],
97+
template: `<button md-tooltip="some message" [tooltip-position]="position">Button</button>`
98+
})
99+
class BasicTooltipDemo {
100+
position: TooltipPosition = 'below';
101+
}

src/components/tooltip/tooltip.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import {Component, ComponentRef, Directive, Input, ElementRef, ViewContainerRef,
2+
ChangeDetectorRef} from '@angular/core';
3+
import {Overlay} from '@angular2-material/core/overlay/overlay';
4+
import {OverlayState} from '@angular2-material/core/overlay/overlay-state';
5+
import {OverlayRef} from '@angular2-material/core/overlay/overlay-ref';
6+
import {ComponentPortal} from '@angular2-material/core/portal/portal';
7+
import {OverlayConnectionPosition, OriginConnectionPosition} from
8+
'@angular2-material/core/overlay/position/connected-position';
9+
10+
export type TooltipPosition = 'before' | 'after' | 'above' | 'below';
11+
12+
@Directive({
13+
selector: '[md-tooltip]',
14+
host: {
15+
'(mouseenter)': '_handleMouseEnter($event)',
16+
'(mouseleave)': '_handleMouseLeave($event)',
17+
}
18+
})
19+
export class MdTooltip {
20+
visible: boolean = false;
21+
22+
/** Allows the user to define the position of the tooltip relative to the parent element */
23+
private _position: TooltipPosition = 'below';
24+
@Input('tooltip-position') get position(): TooltipPosition {
25+
return this._position;
26+
}
27+
set position(value: TooltipPosition) {
28+
if (value !== this._position) {
29+
this._position = value;
30+
this._createOverlay();
31+
this._updatePosition();
32+
}
33+
}
34+
35+
/** The message to be displayed in the tooltip */
36+
private _message: string;
37+
@Input('md-tooltip') get message() {
38+
return this._message;
39+
}
40+
set message(value: string) {
41+
this._message = value;
42+
this._updatePosition();
43+
}
44+
45+
private _overlayRef: OverlayRef;
46+
47+
constructor(private _overlay: Overlay, private _elementRef: ElementRef,
48+
private _viewContainerRef: ViewContainerRef,
49+
private _changeDetectionRef: ChangeDetectorRef) {}
50+
51+
/**
52+
* Create overlay on init
53+
* TODO: @internal
54+
*/
55+
ngOnInit() {
56+
this._createOverlay();
57+
}
58+
59+
/**
60+
* Create the overlay config and position strategy
61+
*/
62+
private _createOverlay() {
63+
if (this._overlayRef) {
64+
if (this.visible) {
65+
// if visible, hide before destroying
66+
this.hide().then(() => this._createOverlay());
67+
} else {
68+
// if not visible, dispose and recreate
69+
this._overlayRef.dispose();
70+
this._overlayRef = null;
71+
this._createOverlay();
72+
}
73+
} else {
74+
let origin = this._getOrigin();
75+
let position = this._getOverlayPosition();
76+
let strategy = this._overlay.position().connectedTo(this._elementRef, origin, position);
77+
let config = new OverlayState();
78+
config.positionStrategy = strategy;
79+
this._overlay.create(config).then(ref => {
80+
this._overlayRef = ref;
81+
});
82+
}
83+
}
84+
85+
/**
86+
* Returns the origin position based on the user's position preference
87+
*/
88+
private _getOrigin(): OriginConnectionPosition {
89+
switch (this.position) {
90+
case 'before': return { originX: 'start', originY: 'center' };
91+
case 'after': return { originX: 'end', originY: 'center' };
92+
case 'above': return { originX: 'center', originY: 'top' };
93+
case 'below': return { originX: 'center', originY: 'bottom' };
94+
}
95+
}
96+
97+
/**
98+
* Returns the overlay position based on the user's preference
99+
*/
100+
private _getOverlayPosition(): OverlayConnectionPosition {
101+
switch (this.position) {
102+
case 'before': return { overlayX: 'end', overlayY: 'center' };
103+
case 'after': return { overlayX: 'start', overlayY: 'center' };
104+
case 'above': return { overlayX: 'center', overlayY: 'bottom' };
105+
case 'below': return { overlayX: 'center', overlayY: 'top' };
106+
}
107+
}
108+
109+
/**
110+
* Shows the tooltip on mouse enter
111+
* @param event
112+
*/
113+
_handleMouseEnter(event: MouseEvent) {
114+
this.show();
115+
}
116+
117+
/**
118+
* Hides the tooltip on mouse leave
119+
* @param event
120+
*/
121+
_handleMouseLeave(event: MouseEvent) {
122+
this.hide();
123+
}
124+
125+
/**
126+
* Shows the tooltip and returns a promise that will resolve when the tooltip is visible
127+
*/
128+
show(): Promise<any> {
129+
if (!this.visible && this._overlayRef && !this._overlayRef.hasAttached()) {
130+
this.visible = true;
131+
let promise = this._overlayRef.attach(new ComponentPortal(TooltipComponent,
132+
this._viewContainerRef));
133+
promise.then((ref: ComponentRef<TooltipComponent>) => {
134+
ref.instance.message = this.message;
135+
this._updatePosition();
136+
});
137+
return promise;
138+
}
139+
}
140+
141+
/**
142+
* Hides the tooltip and returns a promise that will resolve when the tooltip is hidden
143+
*/
144+
hide(): Promise<any> {
145+
if (this.visible && this._overlayRef && this._overlayRef.hasAttached()) {
146+
this.visible = false;
147+
return this._overlayRef.detach();
148+
}
149+
}
150+
151+
/**
152+
* Shows/hides the tooltip and returns a promise that will resolve when it is done
153+
*/
154+
toggle(): Promise<any> {
155+
if (this.visible) {
156+
return this.hide();
157+
} else {
158+
return this.show();
159+
}
160+
}
161+
162+
/**
163+
* Updates the tooltip's position
164+
*/
165+
private _updatePosition() {
166+
if (this._overlayRef) {
167+
this._changeDetectionRef.detectChanges();
168+
this._overlayRef.updatePosition();
169+
}
170+
}
171+
}
172+
173+
@Component({
174+
moduleId: module.id,
175+
selector: 'md-tooltip-component',
176+
template: `<div class="md-tooltip">{{message}}</div>`,
177+
styleUrls: ['tooltip.css'],
178+
})
179+
class TooltipComponent {
180+
message: string;
181+
}
182+
183+
export const MD_TOOLTIP_DIRECTIVES = [MdTooltip];

src/core/overlay/overlay-ref.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export class OverlayRef implements PortalHost {
1717
// Don't chain the .then() call in the return because we want the result of portalHost.attach
1818
// to be returned from this method.
1919
attachPromise.then(() => {
20-
this._updatePosition();
20+
this.updatePosition();
2121
});
2222

2323
return attachPromise;
@@ -41,7 +41,7 @@ export class OverlayRef implements PortalHost {
4141
}
4242

4343
/** Updates the position of the overlay based on the position strategy. */
44-
private _updatePosition() {
44+
updatePosition() {
4545
if (this._state.positionStrategy) {
4646
this._state.positionStrategy.apply(this._pane);
4747
}

src/demo-app/demo-app/demo-app.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
<a md-list-item [routerLink]="['live-announcer']">Live Announcer</a>
1616
<a md-list-item [routerLink]="['overlay']">Overlay</a>
1717
<a md-list-item [routerLink]="['portal']">Portal</a>
18-
<a md-list-item [routerLink]="['progress-circle']">Progress Circle</a>
1918
<a md-list-item [routerLink]="['progress-bar']">Progress Bar</a>
19+
<a md-list-item [routerLink]="['progress-circle']">Progress Circle</a>
2020
<a md-list-item [routerLink]="['radio']">Radio</a>
2121
<a md-list-item [routerLink]="['sidenav']">Sidenav</a>
2222
<a md-list-item [routerLink]="['slider']">Slider</a>
2323
<a md-list-item [routerLink]="['slide-toggle']">Slide Toggle</a>
24-
<a md-list-item [routerLink]="['toolbar']">Toolbar</a>
2524
<a md-list-item [routerLink]="['tabs']">Tabs</a>
25+
<a md-list-item [routerLink]="['toolbar']">Toolbar</a>
26+
<a md-list-item [routerLink]="['tooltip']">Tooltip</a>
2627
<hr>
2728
<a md-list-item [routerLink]="['baseline']">Baseline</a>
2829
</md-nav-list>

src/demo-app/demo-app/routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {RadioDemo} from '../radio/radio-demo';
2323
import {CardDemo} from '../card/card-demo';
2424
import {MenuDemo} from '../menu/menu-demo';
2525
import {DialogDemo} from '../dialog/dialog-demo';
26-
26+
import {TooltipDemo} from '../tooltip/tooltip-demo';
2727

2828

2929
export const routes: RouterConfig = [
@@ -51,6 +51,7 @@ export const routes: RouterConfig = [
5151
{path: 'button-toggle', component: ButtonToggleDemo},
5252
{path: 'baseline', component: BaselineDemo},
5353
{path: 'dialog', component: DialogDemo},
54+
{path: 'tooltip', component: TooltipDemo},
5455
];
5556

5657
export const DEMO_APP_ROUTE_PROVIDER = provideRouter(routes);

src/demo-app/system-config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ const components = [
2020
'slide-toggle',
2121
'button-toggle',
2222
'tabs',
23-
'toolbar'
23+
'toolbar',
24+
'tooltip',
2425
];
2526

2627
/** Map relative paths to URLs. */

0 commit comments

Comments
 (0)