Scroll down while tooltip is open to see it hide automatically
+
+
diff --git a/src/demo-app/tooltip/tooltip-demo.scss b/src/demo-app/tooltip/tooltip-demo.scss
index d3ff6bac1a6c..c2483c856273 100644
--- a/src/demo-app/tooltip/tooltip-demo.scss
+++ b/src/demo-app/tooltip/tooltip-demo.scss
@@ -1,6 +1,12 @@
.demo-tooltip {
.centered {
text-align: center;
+ height: 200px;
+ overflow: auto;
+
+ button {
+ margin: 16px;
+ }
}
.mat-radio-button {
display: block;
diff --git a/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts b/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts
index f342d56cb10d..77342af30f20 100644
--- a/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts
+++ b/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts
@@ -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';
@@ -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.
@@ -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', () => {
@@ -107,7 +117,6 @@ class ScrollingComponent {
})
class NestedScrollingComponent {
@ViewChild('interestingElement') interestingElement: ElementRef;
- @ViewChildren(Scrollable) scrollables: QueryList;
}
const TEST_COMPONENTS = [ScrollingComponent, NestedScrollingComponent];
diff --git a/src/lib/core/overlay/scroll/scroll-dispatcher.ts b/src/lib/core/overlay/scroll/scroll-dispatcher.ts
index e4f8b3864713..15cefb2d2443 100644
--- a/src/lib/core/overlay/scroll/scroll-dispatcher.ts
+++ b/src/lib/core/overlay/scroll/scroll-dispatcher.ts
@@ -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.
@@ -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 {
- // 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 {
+ // 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. */
@@ -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.
diff --git a/src/lib/sidenav/sidenav-container.html b/src/lib/sidenav/sidenav-container.html
index 75eb4b037ab8..24f0789dcde2 100644
--- a/src/lib/sidenav/sidenav-container.html
+++ b/src/lib/sidenav/sidenav-container.html
@@ -3,6 +3,6 @@
-
+
diff --git a/src/lib/tooltip/tooltip.spec.ts b/src/lib/tooltip/tooltip.spec.ts
index 7305715ca920..5873f340f0e6 100644
--- a/src/lib/tooltip/tooltip.spec.ts
+++ b/src/lib/tooltip/tooltip.spec.ts
@@ -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';
@@ -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: () => {
@@ -312,6 +315,43 @@ describe('MdTooltip', () => {
});
});
+ describe('scrollable usage', () => {
+ let fixture: ComponentFixture;
+ let buttonDebugElement: DebugElement;
+ let buttonElement: HTMLButtonElement;
+ let tooltipDirective: MdTooltip;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ScrollableTooltipDemo);
+ fixture.detectChanges();
+ buttonDebugElement = fixture.debugElement.query(By.css('button'));
+ buttonElement = 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;
let buttonDebugElement: DebugElement;
@@ -374,6 +414,39 @@ class BasicTooltipDemo {
message: string = initialTooltipMessage;
showButton: boolean = true;
}
+
+@Component({
+ selector: 'app',
+ template: `
+
+
+
`
+})
+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: `
@@ -387,4 +460,3 @@ class OnPushTooltipDemo {
position: string = 'below';
message: string = initialTooltipMessage;
}
-
diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts
index 94dc7780062a..7b27abaa3968 100644
--- a/src/lib/tooltip/tooltip.ts
+++ b/src/lib/tooltip/tooltip.ts
@@ -15,6 +15,7 @@ import {
NgZone,
Optional,
OnDestroy,
+ OnInit,
ChangeDetectorRef
} from '@angular/core';
import {
@@ -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).
@@ -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';
@@ -123,11 +130,22 @@ 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.
*/
@@ -135,6 +153,8 @@ export class MdTooltip implements OnDestroy {
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 */
@@ -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;
@@ -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);
@@ -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);
diff --git a/tools/gulp/tasks/components.ts b/tools/gulp/tasks/components.ts
index bf00567ebef9..f2cc6030ef45 100644
--- a/tools/gulp/tasks/components.ts
+++ b/tools/gulp/tasks/components.ts
@@ -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',