Skip to content

Commit

Permalink
feat(tooltip): reposition on scroll (#2703)
Browse files Browse the repository at this point in the history
* feat(scrolling): add throttle to scroll dispatcher; add scroll adjust to tooltip

* finish tests

* adjust demo styling

* remove events logger from scroll dispatcher

* fix auditTime in components.ts

* skip auditTime if set to 0ms

* fix tests

* fixing tests

* fix IE and FF test failures

* import fix
  • Loading branch information
andrewseguin authored and tinayuangao committed Feb 9, 2017
1 parent 998a583 commit bc52298
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 20 deletions.
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;
}

35 changes: 33 additions & 2 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
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

0 comments on commit bc52298

Please sign in to comment.