diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 2a314300e8d7..d5cde41cd642 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -47,6 +47,7 @@ export { } from './overlay/overlay-directives'; export * from './overlay/position/connected-position-strategy'; export * from './overlay/position/connected-position'; +export {ScrollDispatcher} from './overlay/scroll/scroll-dispatcher'; // Gestures export {GestureConfig} from './gestures/gesture-config'; @@ -109,8 +110,22 @@ export {NoConflictStyleCompatibilityMode} from './compatibility/no-conflict-mode @NgModule({ - imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule], - exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule], + imports: [ + MdLineModule, + RtlModule, + MdRippleModule, + PortalModule, + OverlayModule, + A11yModule, + ], + exports: [ + MdLineModule, + RtlModule, + MdRippleModule, + PortalModule, + OverlayModule, + A11yModule, + ], }) export class MdCoreModule { static forRoot(): ModuleWithProviders { diff --git a/src/lib/core/overlay/overlay-directives.ts b/src/lib/core/overlay/overlay-directives.ts index 2933c9906a03..258820a331be 100644 --- a/src/lib/core/overlay/overlay-directives.ts +++ b/src/lib/core/overlay/overlay-directives.ts @@ -23,6 +23,7 @@ import {PortalModule} from '../portal/portal-directives'; import {ConnectedPositionStrategy} from './position/connected-position-strategy'; import {Subscription} from 'rxjs/Subscription'; import {Dir, LayoutDirection} from '../rtl/dir'; +import {Scrollable} from './scroll/scrollable'; /** Default set of positions for the overlay. Follows the behavior of a dropdown. */ let defaultPositionList = [ @@ -285,8 +286,8 @@ export class ConnectedOverlayDirective implements OnDestroy { @NgModule({ imports: [PortalModule], - exports: [ConnectedOverlayDirective, OverlayOrigin], - declarations: [ConnectedOverlayDirective, OverlayOrigin], + exports: [ConnectedOverlayDirective, OverlayOrigin, Scrollable], + declarations: [ConnectedOverlayDirective, OverlayOrigin, Scrollable], }) export class OverlayModule { static forRoot(): ModuleWithProviders { diff --git a/src/lib/core/overlay/overlay.ts b/src/lib/core/overlay/overlay.ts index 77e341a117b8..24b51a617c98 100644 --- a/src/lib/core/overlay/overlay.ts +++ b/src/lib/core/overlay/overlay.ts @@ -11,6 +11,7 @@ import {OverlayRef} from './overlay-ref'; import {OverlayPositionBuilder} from './position/overlay-position-builder'; import {ViewportRuler} from './position/viewport-ruler'; import {OverlayContainer} from './overlay-container'; +import {ScrollDispatcher} from './scroll/scroll-dispatcher'; /** Next overlay unique ID. */ let nextUniqueId = 0; @@ -93,4 +94,5 @@ export const OVERLAY_PROVIDERS = [ OverlayPositionBuilder, Overlay, OverlayContainer, + ScrollDispatcher, ]; diff --git a/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts b/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts new file mode 100644 index 000000000000..8975198ab9ce --- /dev/null +++ b/src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts @@ -0,0 +1,79 @@ +import {inject, TestBed, async, ComponentFixture} 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'; + +describe('Scroll Dispatcher', () => { + let scroll: ScrollDispatcher; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [OverlayModule.forRoot(), ScrollTestModule], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([ScrollDispatcher], (s: ScrollDispatcher) => { + scroll = s; + + fixture = TestBed.createComponent(ScrollingComponent); + fixture.detectChanges(); + })); + + it('should be registered with the scrollable directive with the scroll service', () => { + const componentScrollable = fixture.componentInstance.scrollable; + expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true); + }); + + it('should have the scrollable directive deregistered when the component is destroyed', () => { + const componentScrollable = fixture.componentInstance.scrollable; + expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true); + + fixture.destroy(); + expect(scroll.scrollableReferences.has(componentScrollable)).toBe(false); + }); + + it('should notify through the directive and service that a scroll event occurred', () => { + 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 + let hasServiceScrollNotified = false; + scroll.scrolled().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. + // The notification should be picked up by the service. + const scrollEvent = document.createEvent('UIEvents'); + scrollEvent.initUIEvent('scroll', true, true, window, 0); + fixture.componentInstance.scrollingElement.nativeElement.dispatchEvent(scrollEvent); + + expect(hasDirectiveScrollNotified).toBe(true); + expect(hasServiceScrollNotified).toBe(true); + }); +}); + + +/** Simple component that contains a large div and can be scrolled. */ +@Component({ + template: `
` +}) +class ScrollingComponent { + @ViewChild(Scrollable) scrollable: Scrollable; + @ViewChild('scrollingElement') scrollingElement: ElementRef; +} + +const TEST_COMPONENTS = [ScrollingComponent]; +@NgModule({ + imports: [OverlayModule], + providers: [ScrollDispatcher], + exports: TEST_COMPONENTS, + declarations: TEST_COMPONENTS, + entryComponents: TEST_COMPONENTS, +}) +class ScrollTestModule { } diff --git a/src/lib/core/overlay/scroll/scroll-dispatcher.ts b/src/lib/core/overlay/scroll/scroll-dispatcher.ts new file mode 100644 index 000000000000..ebf49fb7c5cf --- /dev/null +++ b/src/lib/core/overlay/scroll/scroll-dispatcher.ts @@ -0,0 +1,60 @@ +import {Injectable} from '@angular/core'; +import {Scrollable} from './scrollable'; +import {Subject} from 'rxjs/Subject'; +import {Observable} from 'rxjs/Observable'; +import {Subscription} from 'rxjs/Subscription'; +import 'rxjs/add/observable/fromEvent'; + + +/** + * Service contained all registered Scrollable references and emits an event when any one of the + * Scrollable references emit a scrolled event. + */ +@Injectable() +export class ScrollDispatcher { + /** Subject for notifying that a registered scrollable reference element has been scrolled. */ + _scrolled: Subject = new Subject(); + + /** + * Map of all the scrollable references that are registered with the service and their + * scroll event subscriptions. + */ + scrollableReferences: WeakMap = new WeakMap(); + + constructor() { + // By default, notify a scroll event when the document is scrolled or the window is resized. + Observable.fromEvent(window.document, 'scroll').subscribe(() => this._notify()); + Observable.fromEvent(window, 'resize').subscribe(() => this._notify()); + } + + /** + * Registers a Scrollable with the service and listens for its scrolled events. When the + * scrollable is scrolled, the service emits the event in its scrolled observable. + */ + register(scrollable: Scrollable): void { + const scrollSubscription = scrollable.elementScrolled().subscribe(() => this._notify()); + this.scrollableReferences.set(scrollable, scrollSubscription); + } + + /** + * Deregisters a Scrollable reference and unsubscribes from its scroll event observable. + */ + deregister(scrollable: Scrollable): void { + this.scrollableReferences.get(scrollable).unsubscribe(); + this.scrollableReferences.delete(scrollable); + } + + /** + * Returns an observable that emits an event whenever any of the registered Scrollable + * references (or window, document, or body) fire a scrolled event. + * TODO: Add an event limiter that includes throttle with the leading and trailing events. + */ + scrolled(): Observable { + return this._scrolled.asObservable(); + } + + /** Sends a notification that a scroll event has been fired. */ + _notify() { + this._scrolled.next(); + } +} diff --git a/src/lib/core/overlay/scroll/scrollable.ts b/src/lib/core/overlay/scroll/scrollable.ts new file mode 100644 index 000000000000..5c66681f3b45 --- /dev/null +++ b/src/lib/core/overlay/scroll/scrollable.ts @@ -0,0 +1,32 @@ +import { + Directive, ElementRef, OnInit, OnDestroy +} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {ScrollDispatcher} from './scroll-dispatcher'; +import 'rxjs/add/observable/fromEvent'; + + +/** + * Sends an event when the directive's element is scrolled. Registers itself with the + * ScrollDispatcher service to include itself as part of its collection of scrolling events that it + * can be listened to through the service. + */ +@Directive({ + selector: '[cdk-scrollable]' +}) +export class Scrollable implements OnInit, OnDestroy { + constructor(private _elementRef: ElementRef, private _scroll: ScrollDispatcher) {} + + ngOnInit() { + this._scroll.register(this); + } + + ngOnDestroy() { + this._scroll.deregister(this); + } + + /** Returns observable that emits when the scroll event is fired on the host element. */ + elementScrolled(): Observable { + return Observable.fromEvent(this._elementRef.nativeElement, 'scroll'); + } +} diff --git a/src/lib/sidenav/sidenav-container.html b/src/lib/sidenav/sidenav-container.html index 652ebf250c62..c916ef399af4 100644 --- a/src/lib/sidenav/sidenav-container.html +++ b/src/lib/sidenav/sidenav-container.html @@ -3,6 +3,6 @@ -
+
diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts index a05bcd011fa3..f2ce5382a3a6 100644 --- a/src/lib/sidenav/sidenav.ts +++ b/src/lib/sidenav/sidenav.ts @@ -17,9 +17,12 @@ import { } from '@angular/core'; import {CommonModule} from '@angular/common'; import {Dir, MdError, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core'; -import {A11yModule, A11Y_PROVIDERS} from '../core/a11y/index'; +import {A11yModule} from '../core/a11y/index'; import {FocusTrap} from '../core/a11y/focus-trap'; import {ESCAPE} from '../core/keyboard/keycodes'; +import {OverlayModule} from '../core/overlay/overlay-directives'; +import {InteractivityChecker} from '../core/a11y/interactivity-checker'; +import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher'; /** Exception thrown when two MdSidenav are matching the same side. */ @@ -503,7 +506,7 @@ export class MdSidenavContainer implements AfterContentInit { @NgModule({ - imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule], + imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule, OverlayModule], exports: [MdSidenavContainer, MdSidenav, DefaultStyleCompatibilityModeModule], declarations: [MdSidenavContainer, MdSidenav], }) @@ -511,7 +514,7 @@ export class MdSidenavModule { static forRoot(): ModuleWithProviders { return { ngModule: MdSidenavModule, - providers: [A11Y_PROVIDERS] + providers: [InteractivityChecker, ScrollDispatcher] }; } } diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts index 61f6ee33aa3f..1c843e5ea4f4 100644 --- a/src/lib/tooltip/tooltip.ts +++ b/src/lib/tooltip/tooltip.ts @@ -14,6 +14,8 @@ import { AnimationTransitionEvent, NgZone, Optional, + OnDestroy, + OnInit } from '@angular/core'; import { Overlay, @@ -23,16 +25,17 @@ import { ComponentPortal, OverlayConnectionPosition, OriginConnectionPosition, - OVERLAY_PROVIDERS, - DefaultStyleCompatibilityModeModule, + DefaultStyleCompatibilityModeModule } from '../core'; import {MdTooltipInvalidPositionError} from './tooltip-errors'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; import {Dir} from '../core/rtl/dir'; +import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher'; +import {OverlayPositionBuilder} from '../core/overlay/position/overlay-position-builder'; +import {ViewportRuler} from '../core/overlay/position/viewport-ruler'; import 'rxjs/add/operator/first'; - export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after'; /** Time in ms to delay before changing the tooltip visibility to hidden */ @@ -54,7 +57,7 @@ export const TOUCHEND_HIDE_DELAY = 1500; }, exportAs: 'mdTooltip', }) -export class MdTooltip { +export class MdTooltip implements OnInit, OnDestroy { _overlayRef: OverlayRef; _tooltipInstance: TooltipComponent; @@ -104,10 +107,23 @@ export class MdTooltip { get _deprecatedMessage(): string { return this.message; } set _deprecatedMessage(v: string) { this.message = v; } - constructor(private _overlay: Overlay, private _elementRef: ElementRef, - private _viewContainerRef: ViewContainerRef, private _ngZone: NgZone, + 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._scrollDispatcher.scrolled().subscribe(() => { + if (this._overlayRef) { + this._overlayRef.updatePosition(); + } + }); + } + /** Dispose the tooltip when destroyed */ ngOnDestroy() { if (this._tooltipInstance) { @@ -370,7 +386,12 @@ export class MdTooltipModule { static forRoot(): ModuleWithProviders { return { ngModule: MdTooltipModule, - providers: OVERLAY_PROVIDERS, + providers: [ + Overlay, + OverlayPositionBuilder, + ViewportRuler, + ScrollDispatcher + ] }; } } diff --git a/tools/gulp/tasks/components.ts b/tools/gulp/tasks/components.ts index e9ab99483d57..995fff674728 100644 --- a/tools/gulp/tasks/components.ts +++ b/tools/gulp/tasks/components.ts @@ -69,6 +69,7 @@ task(':build:components:rollup', () => { // Rxjs dependencies 'rxjs/Subject': 'Rx', + 'rxjs/add/observable/fromEvent': 'Rx.Observable', 'rxjs/add/observable/forkJoin': 'Rx.Observable', 'rxjs/add/observable/of': 'Rx.Observable', 'rxjs/add/operator/toPromise': 'Rx.Observable.prototype',