Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tooltip): initial tooltip implementation #799

Merged
merged 1 commit into from
Jul 21, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/components/tooltip/README.md
Original file line number Diff line number Diff line change
@@ -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
<button md-tooltip="some message" tooltip-position="below">Button</button>
```

## `[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. |
Empty file.
22 changes: 22 additions & 0 deletions src/components/tooltip/tooltip.scss
Original file line number Diff line number Diff line change
@@ -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;
}
101 changes: 101 additions & 0 deletions src/components/tooltip/tooltip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
it,
describe,
expect,
beforeEach,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once #843 goes in, the imports for jasmine functions (it, beforeEach, etc.) go away. Also beforeEachProviders and provide go away.

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<BasicTooltipDemo>;
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 = <HTMLButtonElement> 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<Function>) {
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(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you copy the comment from my dialog PR?

callbacks[0]();
whenStable(callbacks.slice(1));
}, 50);
});
}
}
});
});

@Component({
selector: 'app',
directives: [MD_TOOLTIP_DIRECTIVES],
template: `<button md-tooltip="some message" [tooltip-position]="position">Button</button>`
})
class BasicTooltipDemo {
position: TooltipPosition = 'below';
}
183 changes: 183 additions & 0 deletions src/components/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
@@ -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() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make this mdTooltip, which will be more in line with ngModel, ngFor, etc.?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving for now (currently, most of Material uses hyphens) - need to discuss a standard convention.

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<any> {
Copy link
Member

@jelbourn jelbourn Jul 12, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually Promise<void>?
(same for hide)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this from the attach method.

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<TooltipComponent>) => {
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<any> {
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<any> {
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: `<div class="md-tooltip">{{message}}</div>`,
styleUrls: ['tooltip.css'],
})
class TooltipComponent {
message: string;
}

export const MD_TOOLTIP_DIRECTIVES = [MdTooltip];
4 changes: 2 additions & 2 deletions src/core/overlay/overlay-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
5 changes: 3 additions & 2 deletions src/demo-app/demo-app/demo-app.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
<a md-list-item [routerLink]="['live-announcer']">Live Announcer</a>
<a md-list-item [routerLink]="['overlay']">Overlay</a>
<a md-list-item [routerLink]="['portal']">Portal</a>
<a md-list-item [routerLink]="['progress-circle']">Progress Circle</a>
<a md-list-item [routerLink]="['progress-bar']">Progress Bar</a>
<a md-list-item [routerLink]="['progress-circle']">Progress Circle</a>
<a md-list-item [routerLink]="['radio']">Radio</a>
<a md-list-item [routerLink]="['sidenav']">Sidenav</a>
<a md-list-item [routerLink]="['slider']">Slider</a>
<a md-list-item [routerLink]="['slide-toggle']">Slide Toggle</a>
<a md-list-item [routerLink]="['toolbar']">Toolbar</a>
<a md-list-item [routerLink]="['tabs']">Tabs</a>
<a md-list-item [routerLink]="['toolbar']">Toolbar</a>
<a md-list-item [routerLink]="['tooltip']">Tooltip</a>
<hr>
<a md-list-item [routerLink]="['baseline']">Baseline</a>
</md-nav-list>
Expand Down
3 changes: 2 additions & 1 deletion src/demo-app/demo-app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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);
3 changes: 2 additions & 1 deletion src/demo-app/system-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const components = [
'slide-toggle',
'button-toggle',
'tabs',
'toolbar'
'toolbar',
'tooltip',
];

/** Map relative paths to URLs. */
Expand Down
Loading