Skip to content

feat(tooltip): reposition on scroll #2703

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

Merged
merged 10 commits into from
Feb 9, 2017
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
6 changes: 4 additions & 2 deletions src/demo-app/tooltip/tooltip-demo.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="demo-tooltip">
<h1>Tooltip Demo</h1>

<p class="centered">
<div class="centered" cdk-scrollable>
<button #tooltip="mdTooltip"
md-raised-button
color="primary"
Expand All @@ -11,7 +11,9 @@ <h1>Tooltip Demo</h1>
[mdTooltipHideDelay]="hideDelay">
Mouse over to see the tooltip
</button>
</p>
<div>Scroll down while tooltip is open to see it hide automatically</div>
<div style="height: 400px;"></div>
</div>

<p>
<md-radio-group [(ngModel)]="position">
Expand Down
6 changes: 6 additions & 0 deletions src/demo-app/tooltip/tooltip-demo.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
.demo-tooltip {
.centered {
text-align: center;
height: 200px;
overflow: auto;

button {
margin: 16px;
}
}
.mat-radio-button {
display: block;
Expand Down
23 changes: 16 additions & 7 deletions src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {inject, TestBed, async, ComponentFixture} from '@angular/core/testing';
import {NgModule, Component, ViewChild, ElementRef, QueryList, ViewChildren} from '@angular/core';
import {inject, TestBed, async, fakeAsync, ComponentFixture, tick} from '@angular/core/testing';
import {NgModule, Component, ViewChild, ElementRef} from '@angular/core';
import {ScrollDispatcher} from './scroll-dispatcher';
import {OverlayModule} from '../overlay-directives';
import {Scrollable} from './scrollable';
Expand Down Expand Up @@ -38,15 +38,17 @@ describe('Scroll Dispatcher', () => {
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(false);
});

it('should notify through the directive and service that a scroll event occurred', () => {
it('should notify through the directive and service that a scroll event occurred',
fakeAsync(() => {
let hasDirectiveScrollNotified = false;
// Listen for notifications from scroll directive
let scrollable = fixture.componentInstance.scrollable;
scrollable.elementScrolled().subscribe(() => { hasDirectiveScrollNotified = true; });

// Listen for notifications from scroll service
// Listen for notifications from scroll service with a throttle of 100ms
const throttleTime = 100;
let hasServiceScrollNotified = false;
scroll.scrolled().subscribe(() => { hasServiceScrollNotified = true; });
scroll.scrolled(throttleTime).subscribe(() => { hasServiceScrollNotified = true; });

// Emit a scroll event from the scrolling element in our component.
// This event should be picked up by the scrollable directive and notify.
Expand All @@ -55,9 +57,17 @@ describe('Scroll Dispatcher', () => {
scrollEvent.initUIEvent('scroll', true, true, window, 0);
fixture.componentInstance.scrollingElement.nativeElement.dispatchEvent(scrollEvent);

// The scrollable directive should have notified the service immediately.
expect(hasDirectiveScrollNotified).toBe(true);

// Verify that the throttle is used, the service should wait for the throttle time until
// sending the notification.
expect(hasServiceScrollNotified).toBe(false);

// After the throttle time, the notification should be sent.
tick(throttleTime);
expect(hasServiceScrollNotified).toBe(true);
});
}));
});

describe('Nested scrollables', () => {
Expand Down Expand Up @@ -107,7 +117,6 @@ class ScrollingComponent {
})
class NestedScrollingComponent {
@ViewChild('interestingElement') interestingElement: ElementRef;
@ViewChildren(Scrollable) scrollables: QueryList<Scrollable>;
}

const TEST_COMPONENTS = [ScrollingComponent, NestedScrollingComponent];
Expand Down
20 changes: 15 additions & 5 deletions src/lib/core/overlay/scroll/scroll-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import {Subject} from 'rxjs/Subject';
import {Observable} from 'rxjs/Observable';
import {Subscription} from 'rxjs/Subscription';
import 'rxjs/add/observable/fromEvent';
import 'rxjs/add/operator/auditTime';


/** Time in ms to throttle the scrolling events by default. */
export const DEFAULT_SCROLL_TIME = 20;

/**
* Service contained all registered Scrollable references and emits an event when any one of the
* Scrollable references emit a scrolled event.
Expand Down Expand Up @@ -50,11 +54,17 @@ export class ScrollDispatcher {

/**
* Returns an observable that emits an event whenever any of the registered Scrollable
* references (or window, document, or body) fire a scrolled event.
* references (or window, document, or body) fire a scrolled event. Can provide a time in ms
* to override the default "throttle" time.
*/
scrolled(): Observable<void> {
// TODO: Add an event limiter that includes throttle with the leading and trailing events.
return this._scrolled.asObservable();
scrolled(auditTimeInMs: number = DEFAULT_SCROLL_TIME): Observable<void> {
// In the case of a 0ms delay, return the observable without auditTime since it does add
// a perceptible delay in processing overhead.
if (auditTimeInMs == 0) {
return this._scrolled.asObservable();
}

return this._scrolled.asObservable().auditTime(auditTimeInMs);
}

/** Returns all registered Scrollables that contain the provided element. */
Expand Down Expand Up @@ -90,7 +100,7 @@ export class ScrollDispatcher {

export function SCROLL_DISPATCHER_PROVIDER_FACTORY(parentDispatcher: ScrollDispatcher) {
return parentDispatcher || new ScrollDispatcher();
};
}

export const SCROLL_DISPATCHER_PROVIDER = {
// If there is already a ScrollDispatcher available, use that. Otherwise, provide a new one.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/sidenav/sidenav-container.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@

<ng-content select="md-sidenav, mat-sidenav"></ng-content>

<div class="mat-sidenav-content" [ngStyle]="_getStyles()">
<div class="mat-sidenav-content" [ngStyle]="_getStyles()" cdk-scrollable>
<ng-content></ng-content>
</div>
78 changes: 75 additions & 3 deletions src/lib/tooltip/tooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import {
Component,
DebugElement,
AnimationTransitionEvent,
ViewChild,
ChangeDetectionStrategy
} from '@angular/core';
import {By} from '@angular/platform-browser';
import {TooltipPosition, MdTooltip, MdTooltipModule} from './tooltip';
import {TooltipPosition, MdTooltip, MdTooltipModule, SCROLL_THROTTLE_MS} from './tooltip';
import {OverlayContainer} from '../core';
import {Dir, LayoutDirection} from '../core/rtl/dir';
import {OverlayModule} from '../core/overlay/overlay-directives';
import {Scrollable} from '../core/overlay/scroll/scrollable';

const initialTooltipMessage = 'initial tooltip message';

Expand All @@ -27,10 +29,11 @@ describe('MdTooltip', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdTooltipModule.forRoot(), OverlayModule],
declarations: [BasicTooltipDemo, OnPushTooltipDemo],
declarations: [BasicTooltipDemo, ScrollableTooltipDemo, OnPushTooltipDemo],
providers: [
{provide: OverlayContainer, useFactory: () => {
overlayContainerElement = document.createElement('div');
document.body.appendChild(overlayContainerElement);
return {getContainerElement: () => overlayContainerElement};
}},
{provide: Dir, useFactory: () => {
Expand Down Expand Up @@ -312,6 +315,43 @@ describe('MdTooltip', () => {
});
});

describe('scrollable usage', () => {
let fixture: ComponentFixture<ScrollableTooltipDemo>;
let buttonDebugElement: DebugElement;
let buttonElement: HTMLButtonElement;
let tooltipDirective: MdTooltip;

beforeEach(() => {
fixture = TestBed.createComponent(ScrollableTooltipDemo);
fixture.detectChanges();
buttonDebugElement = fixture.debugElement.query(By.css('button'));
buttonElement = <HTMLButtonElement> buttonDebugElement.nativeElement;
tooltipDirective = buttonDebugElement.injector.get(MdTooltip);
});

it('should hide tooltip if clipped after changing positions', fakeAsync(() => {
expect(tooltipDirective._tooltipInstance).toBeUndefined();

// Show the tooltip and tick for the show delay (default is 0)
tooltipDirective.show();
fixture.detectChanges();
tick(0);

// Expect that the tooltip is displayed
expect(tooltipDirective._isTooltipVisible()).toBe(true);

// Scroll the page but tick just before the default throttle should update.
fixture.componentInstance.scrollDown();
tick(SCROLL_THROTTLE_MS - 1);
expect(tooltipDirective._isTooltipVisible()).toBe(true);

// Finish ticking to the throttle's limit and check that the scroll event notified the
// tooltip and it was hidden.
tick(1);
expect(tooltipDirective._isTooltipVisible()).toBe(false);
}));
});

describe('with OnPush', () => {
let fixture: ComponentFixture<OnPushTooltipDemo>;
let buttonDebugElement: DebugElement;
Expand Down Expand Up @@ -374,6 +414,39 @@ class BasicTooltipDemo {
message: string = initialTooltipMessage;
showButton: boolean = true;
}

@Component({
selector: 'app',
template: `
<div cdk-scrollable style="padding: 100px; margin: 300px;
height: 200px; width: 200px; overflow: auto;">
<button *ngIf="showButton" style="margin-bottom: 600px"
[md-tooltip]="message"
[tooltip-position]="position">
Button
</button>
</div>`
})
class ScrollableTooltipDemo {
position: string = 'below';
message: string = initialTooltipMessage;
showButton: boolean = true;

@ViewChild(Scrollable) scrollingContainer: Scrollable;

scrollDown() {
const scrollingContainerEl = this.scrollingContainer.getElementRef().nativeElement;
scrollingContainerEl.scrollTop = 250;

// Emit a scroll event from the scrolling element in our component.
// This event should be picked up by the scrollable directive and notify.
// The notification should be picked up by the service.
const scrollEvent = document.createEvent('UIEvents');
scrollEvent.initUIEvent('scroll', true, true, window, 0);
scrollingContainerEl.dispatchEvent(scrollEvent);
}
}

@Component({
selector: 'app',
template: `
Expand All @@ -387,4 +460,3 @@ class OnPushTooltipDemo {
position: string = 'below';
message: string = initialTooltipMessage;
}

39 changes: 35 additions & 4 deletions src/lib/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
NgZone,
Optional,
OnDestroy,
OnInit,
ChangeDetectorRef
} from '@angular/core';
import {
Expand All @@ -25,19 +26,24 @@ import {
ComponentPortal,
OverlayConnectionPosition,
OriginConnectionPosition,
CompatibilityModule,
CompatibilityModule
} from '../core';
import {MdTooltipInvalidPositionError} from './tooltip-errors';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import {Dir} from '../core/rtl/dir';
import 'rxjs/add/operator/first';
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
import {Subscription} from 'rxjs/Subscription';

export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after';

/** Time in ms to delay before changing the tooltip visibility to hidden */
export const TOUCHEND_HIDE_DELAY = 1500;

/** Time in ms to throttle repositioning after scroll events. */
export const SCROLL_THROTTLE_MS = 20;

/**
* 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).
Expand All @@ -54,9 +60,10 @@ export const TOUCHEND_HIDE_DELAY = 1500;
},
exportAs: 'mdTooltip',
})
export class MdTooltip implements OnDestroy {
export class MdTooltip implements OnInit, OnDestroy {
_overlayRef: OverlayRef;
_tooltipInstance: TooltipComponent;
scrollSubscription: Subscription;

private _position: TooltipPosition = 'below';

Expand Down Expand Up @@ -123,18 +130,31 @@ export class MdTooltip implements OnDestroy {
set _matShowDelay(v) { this.showDelay = v; }

constructor(private _overlay: Overlay,
private _scrollDispatcher: ScrollDispatcher,
private _elementRef: ElementRef,
private _viewContainerRef: ViewContainerRef,
private _ngZone: NgZone,
@Optional() private _dir: Dir) { }

ngOnInit() {
// When a scroll on the page occurs, update the position in case this tooltip needs
// to be repositioned.
this.scrollSubscription = this._scrollDispatcher.scrolled(SCROLL_THROTTLE_MS).subscribe(() => {
if (this._overlayRef) {
this._overlayRef.updatePosition();
}
});
}

/**
* Dispose the tooltip when destroyed.
*/
ngOnDestroy() {
if (this._tooltipInstance) {
this._disposeTooltip();
}

this.scrollSubscription.unsubscribe();
}

/** Shows the tooltip after the delay in ms, defaults to tooltip-delay-show or 0ms if no input */
Expand Down Expand Up @@ -185,7 +205,18 @@ export class MdTooltip implements OnDestroy {
private _createOverlay(): void {
let origin = this._getOrigin();
let position = this._getOverlayPosition();

// Create connected position strategy that listens for scroll events to reposition.
// After position changes occur and the overlay is clipped by a parent scrollable then
// close the tooltip.
let strategy = this._overlay.position().connectedTo(this._elementRef, origin, position);
strategy.withScrollableContainers(this._scrollDispatcher.getScrollContainers(this._elementRef));
strategy.onPositionChange.subscribe(change => {
if (change.scrollableViewProperties.isOverlayClipped &&
this._tooltipInstance && this._tooltipInstance.isVisible()) {
this.hide(0);
}
});
let config = new OverlayState();
config.positionStrategy = strategy;

Expand Down Expand Up @@ -331,7 +362,7 @@ export class TooltipComponent {
// trigger interaction and close the tooltip right after it was displayed.
this._closeOnInteraction = false;

// Mark for check so if any parent component has set the
// Mark for check so if any parent component has set the
// ChangeDetectionStrategy to OnPush it will be checked anyways
this._changeDetectorRef.markForCheck();
setTimeout(() => { this._closeOnInteraction = true; }, 0);
Expand All @@ -352,7 +383,7 @@ export class TooltipComponent {
this._visibility = 'hidden';
this._closeOnInteraction = false;

// Mark for check so if any parent component has set the
// Mark for check so if any parent component has set the
// ChangeDetectionStrategy to OnPush it will be checked anyways
this._changeDetectorRef.markForCheck();
}, delay);
Expand Down
1 change: 1 addition & 0 deletions tools/gulp/tasks/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ task(':build:components:rollup', () => {
'rxjs/add/observable/of': 'Rx.Observable',
'rxjs/add/observable/merge': 'Rx.Observable',
'rxjs/add/observable/throw': 'Rx.Observable',
'rxjs/add/operator/auditTime': 'Rx.Observable.prototype',
'rxjs/add/operator/toPromise': 'Rx.Observable.prototype',
'rxjs/add/operator/map': 'Rx.Observable.prototype',
'rxjs/add/operator/filter': 'Rx.Observable.prototype',
Expand Down