Skip to content

Commit bc52298

Browse files
andrewseguintinayuangao
authored andcommitted
feat(tooltip): reposition on scroll (#2703)
* 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
1 parent 998a583 commit bc52298

File tree

8 files changed

+151
-20
lines changed

8 files changed

+151
-20
lines changed

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<div class="demo-tooltip">
22
<h1>Tooltip Demo</h1>
33

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

1618
<p>
1719
<md-radio-group [(ngModel)]="position">

src/demo-app/tooltip/tooltip-demo.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
.demo-tooltip {
22
.centered {
33
text-align: center;
4+
height: 200px;
5+
overflow: auto;
6+
7+
button {
8+
margin: 16px;
9+
}
410
}
511
.mat-radio-button {
612
display: block;

src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {inject, TestBed, async, ComponentFixture} from '@angular/core/testing';
2-
import {NgModule, Component, ViewChild, ElementRef, QueryList, ViewChildren} from '@angular/core';
1+
import {inject, TestBed, async, fakeAsync, ComponentFixture, tick} from '@angular/core/testing';
2+
import {NgModule, Component, ViewChild, ElementRef} from '@angular/core';
33
import {ScrollDispatcher} from './scroll-dispatcher';
44
import {OverlayModule} from '../overlay-directives';
55
import {Scrollable} from './scrollable';
@@ -38,15 +38,17 @@ describe('Scroll Dispatcher', () => {
3838
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(false);
3939
});
4040

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

47-
// Listen for notifications from scroll service
48+
// Listen for notifications from scroll service with a throttle of 100ms
49+
const throttleTime = 100;
4850
let hasServiceScrollNotified = false;
49-
scroll.scrolled().subscribe(() => { hasServiceScrollNotified = true; });
51+
scroll.scrolled(throttleTime).subscribe(() => { hasServiceScrollNotified = true; });
5052

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

60+
// The scrollable directive should have notified the service immediately.
5861
expect(hasDirectiveScrollNotified).toBe(true);
62+
63+
// Verify that the throttle is used, the service should wait for the throttle time until
64+
// sending the notification.
65+
expect(hasServiceScrollNotified).toBe(false);
66+
67+
// After the throttle time, the notification should be sent.
68+
tick(throttleTime);
5969
expect(hasServiceScrollNotified).toBe(true);
60-
});
70+
}));
6171
});
6272

6373
describe('Nested scrollables', () => {
@@ -107,7 +117,6 @@ class ScrollingComponent {
107117
})
108118
class NestedScrollingComponent {
109119
@ViewChild('interestingElement') interestingElement: ElementRef;
110-
@ViewChildren(Scrollable) scrollables: QueryList<Scrollable>;
111120
}
112121

113122
const TEST_COMPONENTS = [ScrollingComponent, NestedScrollingComponent];

src/lib/core/overlay/scroll/scroll-dispatcher.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import {Subject} from 'rxjs/Subject';
44
import {Observable} from 'rxjs/Observable';
55
import {Subscription} from 'rxjs/Subscription';
66
import 'rxjs/add/observable/fromEvent';
7+
import 'rxjs/add/operator/auditTime';
78

89

10+
/** Time in ms to throttle the scrolling events by default. */
11+
export const DEFAULT_SCROLL_TIME = 20;
12+
913
/**
1014
* Service contained all registered Scrollable references and emits an event when any one of the
1115
* Scrollable references emit a scrolled event.
@@ -50,11 +54,17 @@ export class ScrollDispatcher {
5054

5155
/**
5256
* Returns an observable that emits an event whenever any of the registered Scrollable
53-
* references (or window, document, or body) fire a scrolled event.
57+
* references (or window, document, or body) fire a scrolled event. Can provide a time in ms
58+
* to override the default "throttle" time.
5459
*/
55-
scrolled(): Observable<void> {
56-
// TODO: Add an event limiter that includes throttle with the leading and trailing events.
57-
return this._scrolled.asObservable();
60+
scrolled(auditTimeInMs: number = DEFAULT_SCROLL_TIME): Observable<void> {
61+
// In the case of a 0ms delay, return the observable without auditTime since it does add
62+
// a perceptible delay in processing overhead.
63+
if (auditTimeInMs == 0) {
64+
return this._scrolled.asObservable();
65+
}
66+
67+
return this._scrolled.asObservable().auditTime(auditTimeInMs);
5868
}
5969

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

91101
export function SCROLL_DISPATCHER_PROVIDER_FACTORY(parentDispatcher: ScrollDispatcher) {
92102
return parentDispatcher || new ScrollDispatcher();
93-
};
103+
}
94104

95105
export const SCROLL_DISPATCHER_PROVIDER = {
96106
// If there is already a ScrollDispatcher available, use that. Otherwise, provide a new one.

src/lib/sidenav/sidenav-container.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33

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

6-
<div class="mat-sidenav-content" [ngStyle]="_getStyles()">
6+
<div class="mat-sidenav-content" [ngStyle]="_getStyles()" cdk-scrollable>
77
<ng-content></ng-content>
88
</div>

src/lib/tooltip/tooltip.spec.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import {
1010
Component,
1111
DebugElement,
1212
AnimationTransitionEvent,
13+
ViewChild,
1314
ChangeDetectionStrategy
1415
} from '@angular/core';
1516
import {By} from '@angular/platform-browser';
16-
import {TooltipPosition, MdTooltip, MdTooltipModule} from './tooltip';
17+
import {TooltipPosition, MdTooltip, MdTooltipModule, SCROLL_THROTTLE_MS} from './tooltip';
1718
import {OverlayContainer} from '../core';
1819
import {Dir, LayoutDirection} from '../core/rtl/dir';
1920
import {OverlayModule} from '../core/overlay/overlay-directives';
21+
import {Scrollable} from '../core/overlay/scroll/scrollable';
2022

2123
const initialTooltipMessage = 'initial tooltip message';
2224

@@ -27,10 +29,11 @@ describe('MdTooltip', () => {
2729
beforeEach(async(() => {
2830
TestBed.configureTestingModule({
2931
imports: [MdTooltipModule.forRoot(), OverlayModule],
30-
declarations: [BasicTooltipDemo, OnPushTooltipDemo],
32+
declarations: [BasicTooltipDemo, ScrollableTooltipDemo, OnPushTooltipDemo],
3133
providers: [
3234
{provide: OverlayContainer, useFactory: () => {
3335
overlayContainerElement = document.createElement('div');
36+
document.body.appendChild(overlayContainerElement);
3437
return {getContainerElement: () => overlayContainerElement};
3538
}},
3639
{provide: Dir, useFactory: () => {
@@ -312,6 +315,43 @@ describe('MdTooltip', () => {
312315
});
313316
});
314317

318+
describe('scrollable usage', () => {
319+
let fixture: ComponentFixture<ScrollableTooltipDemo>;
320+
let buttonDebugElement: DebugElement;
321+
let buttonElement: HTMLButtonElement;
322+
let tooltipDirective: MdTooltip;
323+
324+
beforeEach(() => {
325+
fixture = TestBed.createComponent(ScrollableTooltipDemo);
326+
fixture.detectChanges();
327+
buttonDebugElement = fixture.debugElement.query(By.css('button'));
328+
buttonElement = <HTMLButtonElement> buttonDebugElement.nativeElement;
329+
tooltipDirective = buttonDebugElement.injector.get(MdTooltip);
330+
});
331+
332+
it('should hide tooltip if clipped after changing positions', fakeAsync(() => {
333+
expect(tooltipDirective._tooltipInstance).toBeUndefined();
334+
335+
// Show the tooltip and tick for the show delay (default is 0)
336+
tooltipDirective.show();
337+
fixture.detectChanges();
338+
tick(0);
339+
340+
// Expect that the tooltip is displayed
341+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
342+
343+
// Scroll the page but tick just before the default throttle should update.
344+
fixture.componentInstance.scrollDown();
345+
tick(SCROLL_THROTTLE_MS - 1);
346+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
347+
348+
// Finish ticking to the throttle's limit and check that the scroll event notified the
349+
// tooltip and it was hidden.
350+
tick(1);
351+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
352+
}));
353+
});
354+
315355
describe('with OnPush', () => {
316356
let fixture: ComponentFixture<OnPushTooltipDemo>;
317357
let buttonDebugElement: DebugElement;
@@ -374,6 +414,39 @@ class BasicTooltipDemo {
374414
message: string = initialTooltipMessage;
375415
showButton: boolean = true;
376416
}
417+
418+
@Component({
419+
selector: 'app',
420+
template: `
421+
<div cdk-scrollable style="padding: 100px; margin: 300px;
422+
height: 200px; width: 200px; overflow: auto;">
423+
<button *ngIf="showButton" style="margin-bottom: 600px"
424+
[md-tooltip]="message"
425+
[tooltip-position]="position">
426+
Button
427+
</button>
428+
</div>`
429+
})
430+
class ScrollableTooltipDemo {
431+
position: string = 'below';
432+
message: string = initialTooltipMessage;
433+
showButton: boolean = true;
434+
435+
@ViewChild(Scrollable) scrollingContainer: Scrollable;
436+
437+
scrollDown() {
438+
const scrollingContainerEl = this.scrollingContainer.getElementRef().nativeElement;
439+
scrollingContainerEl.scrollTop = 250;
440+
441+
// Emit a scroll event from the scrolling element in our component.
442+
// This event should be picked up by the scrollable directive and notify.
443+
// The notification should be picked up by the service.
444+
const scrollEvent = document.createEvent('UIEvents');
445+
scrollEvent.initUIEvent('scroll', true, true, window, 0);
446+
scrollingContainerEl.dispatchEvent(scrollEvent);
447+
}
448+
}
449+
377450
@Component({
378451
selector: 'app',
379452
template: `
@@ -387,4 +460,3 @@ class OnPushTooltipDemo {
387460
position: string = 'below';
388461
message: string = initialTooltipMessage;
389462
}
390-

src/lib/tooltip/tooltip.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
NgZone,
1616
Optional,
1717
OnDestroy,
18+
OnInit,
1819
ChangeDetectorRef
1920
} from '@angular/core';
2021
import {
@@ -25,19 +26,24 @@ import {
2526
ComponentPortal,
2627
OverlayConnectionPosition,
2728
OriginConnectionPosition,
28-
CompatibilityModule,
29+
CompatibilityModule
2930
} from '../core';
3031
import {MdTooltipInvalidPositionError} from './tooltip-errors';
3132
import {Observable} from 'rxjs/Observable';
3233
import {Subject} from 'rxjs/Subject';
3334
import {Dir} from '../core/rtl/dir';
3435
import 'rxjs/add/operator/first';
36+
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
37+
import {Subscription} from 'rxjs/Subscription';
3538

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

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

44+
/** Time in ms to throttle repositioning after scroll events. */
45+
export const SCROLL_THROTTLE_MS = 20;
46+
4147
/**
4248
* Directive that attaches a material design tooltip to the host element. Animates the showing and
4349
* hiding of a tooltip provided position (defaults to below the element).
@@ -54,9 +60,10 @@ export const TOUCHEND_HIDE_DELAY = 1500;
5460
},
5561
exportAs: 'mdTooltip',
5662
})
57-
export class MdTooltip implements OnDestroy {
63+
export class MdTooltip implements OnInit, OnDestroy {
5864
_overlayRef: OverlayRef;
5965
_tooltipInstance: TooltipComponent;
66+
scrollSubscription: Subscription;
6067

6168
private _position: TooltipPosition = 'below';
6269

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

125132
constructor(private _overlay: Overlay,
133+
private _scrollDispatcher: ScrollDispatcher,
126134
private _elementRef: ElementRef,
127135
private _viewContainerRef: ViewContainerRef,
128136
private _ngZone: NgZone,
129137
@Optional() private _dir: Dir) { }
130138

139+
ngOnInit() {
140+
// When a scroll on the page occurs, update the position in case this tooltip needs
141+
// to be repositioned.
142+
this.scrollSubscription = this._scrollDispatcher.scrolled(SCROLL_THROTTLE_MS).subscribe(() => {
143+
if (this._overlayRef) {
144+
this._overlayRef.updatePosition();
145+
}
146+
});
147+
}
148+
131149
/**
132150
* Dispose the tooltip when destroyed.
133151
*/
134152
ngOnDestroy() {
135153
if (this._tooltipInstance) {
136154
this._disposeTooltip();
137155
}
156+
157+
this.scrollSubscription.unsubscribe();
138158
}
139159

140160
/** Shows the tooltip after the delay in ms, defaults to tooltip-delay-show or 0ms if no input */
@@ -185,7 +205,18 @@ export class MdTooltip implements OnDestroy {
185205
private _createOverlay(): void {
186206
let origin = this._getOrigin();
187207
let position = this._getOverlayPosition();
208+
209+
// Create connected position strategy that listens for scroll events to reposition.
210+
// After position changes occur and the overlay is clipped by a parent scrollable then
211+
// close the tooltip.
188212
let strategy = this._overlay.position().connectedTo(this._elementRef, origin, position);
213+
strategy.withScrollableContainers(this._scrollDispatcher.getScrollContainers(this._elementRef));
214+
strategy.onPositionChange.subscribe(change => {
215+
if (change.scrollableViewProperties.isOverlayClipped &&
216+
this._tooltipInstance && this._tooltipInstance.isVisible()) {
217+
this.hide(0);
218+
}
219+
});
189220
let config = new OverlayState();
190221
config.positionStrategy = strategy;
191222

tools/gulp/tasks/components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ task(':build:components:rollup', () => {
7777
'rxjs/add/observable/of': 'Rx.Observable',
7878
'rxjs/add/observable/merge': 'Rx.Observable',
7979
'rxjs/add/observable/throw': 'Rx.Observable',
80+
'rxjs/add/operator/auditTime': 'Rx.Observable.prototype',
8081
'rxjs/add/operator/toPromise': 'Rx.Observable.prototype',
8182
'rxjs/add/operator/map': 'Rx.Observable.prototype',
8283
'rxjs/add/operator/filter': 'Rx.Observable.prototype',

0 commit comments

Comments
 (0)