diff --git a/src/demo-app/tooltip/tooltip-demo.html b/src/demo-app/tooltip/tooltip-demo.html index 79691037bea0..15025d7a1df3 100644 --- a/src/demo-app/tooltip/tooltip-demo.html +++ b/src/demo-app/tooltip/tooltip-demo.html @@ -1,7 +1,7 @@

Tooltip Demo

-

+

-

+
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',