diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 0791f8842195..a672690d97e4 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -6,6 +6,8 @@ import {PortalModule} from './portal/portal-directives'; import {OverlayModule} from './overlay/overlay-directives'; import {A11yModule, A11Y_PROVIDERS} from './a11y/index'; import {OVERLAY_PROVIDERS} from './overlay/overlay'; +import {Scroll} from './scroll/scroll'; +import {ScrollModule} from './scroll/scrollable'; // RTL @@ -45,6 +47,8 @@ export { } from './overlay/overlay-directives'; export * from './overlay/position/connected-position-strategy'; export * from './overlay/position/connected-position'; +export * from './scroll/scrollable'; +export * from './scroll/scroll'; // Gestures export {MdGestureConfig} from './gestures/MdGestureConfig'; @@ -97,16 +101,33 @@ export {coerceNumberProperty} from './coersion/number-property'; export {DefaultStyleCompatibilityModeModule} from './compatibility/default-mode'; export {NoConflictStyleCompatibilityMode} from './compatibility/no-conflict-mode'; +// Scroll +export {Scroll} from './scroll/scroll'; +export {Scrollable} from './scroll/scrollable'; @NgModule({ - imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule], - exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule], + imports: [MdLineModule, + RtlModule, + MdRippleModule, + PortalModule, + OverlayModule, + A11yModule, + ScrollModule + ], + exports: [MdLineModule, + RtlModule, + MdRippleModule, + PortalModule, + OverlayModule, + A11yModule, + ScrollModule + ], }) export class MdCoreModule { static forRoot(): ModuleWithProviders { return { ngModule: MdCoreModule, - providers: [A11Y_PROVIDERS, OVERLAY_PROVIDERS], + providers: [A11Y_PROVIDERS, OVERLAY_PROVIDERS, Scroll], }; } } diff --git a/src/lib/core/scroll/scroll.spec.ts b/src/lib/core/scroll/scroll.spec.ts new file mode 100644 index 000000000000..91821a950a29 --- /dev/null +++ b/src/lib/core/scroll/scroll.spec.ts @@ -0,0 +1,76 @@ +import {inject, TestBed, async, ComponentFixture} from '@angular/core/testing'; +import {NgModule, Component, ViewChild, ElementRef} from '@angular/core'; +import {Scroll} from './scroll'; +import {ScrollModule, Scrollable} from './scrollable'; + +describe('Scrollable', () => { + let scroll: Scroll; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ScrollModule.forRoot(), ScrollTestModule], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([Scroll], (s: Scroll) => { + scroll = s; + + fixture = TestBed.createComponent(ScrollingComponent); + fixture.detectChanges(); + })); + + it('should register the scrollable directive with the scroll service', () => { + const componentScrollable = fixture.componentInstance.scrollable; + expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true); + }); + + it('should deregister the scrollable directive 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. + fixture.componentInstance.scrollingElement.nativeElement.dispatchEvent(new Event('scroll')); + + 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: [ScrollModule], + providers: [Scroll], + exports: TEST_COMPONENTS, + declarations: TEST_COMPONENTS, + entryComponents: TEST_COMPONENTS, +}) +class ScrollTestModule { } diff --git a/src/lib/core/scroll/scroll.ts b/src/lib/core/scroll/scroll.ts new file mode 100644 index 000000000000..2e9c823a66d5 --- /dev/null +++ b/src/lib/core/scroll/scroll.ts @@ -0,0 +1,59 @@ +import {Injectable} from '@angular/core'; +import {Scrollable} from './scrollable'; +import {Subject} from 'rxjs/Subject'; +import {Observable} from 'rxjs/Observable'; +import {Subscription} from 'rxjs/Subscription'; + + +/** + * Service contained all registered Scrollable references and emits an event when any one of the + * Scrollable references emit a scrolled event. + */ +@Injectable() +export class Scroll { + /** 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: Map = new Map(); + + constructor() { + // By default, notify a scroll event when the document is scrolled or the window is resized. + window.document.addEventListener('scroll', this._notify.bind(this)); + window.addEventListener('resize', this._notify.bind(this)); + } + + /** + * 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.bind(this)); + 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(e: Event) { + this._scrolled.next(e); + } +} diff --git a/src/lib/core/scroll/scrollable.ts b/src/lib/core/scroll/scrollable.ts new file mode 100644 index 000000000000..5998a8a17874 --- /dev/null +++ b/src/lib/core/scroll/scrollable.ts @@ -0,0 +1,53 @@ +import { + Directive, ElementRef, OnInit, OnDestroy, ModuleWithProviders, + NgModule +} from '@angular/core'; +import {Subject} from 'rxjs/Subject'; +import {Observable} from 'rxjs/Observable'; +import {Scroll} from './scroll'; + + +/** + * Sends an event when the directive's element is scrolled. Registers itself with the Scroll + * service to include itself as part of its collection of scrolling events that it can be listened + * to through the service. + */ +@Directive({ + selector: '[md-scrollable]' +}) +export class Scrollable implements OnInit, OnDestroy { + /** Subject for notifying that the element has been scrolled. */ + private _elementScrolled: Subject = new Subject(); + + constructor(private _elementRef: ElementRef, private _scroll: Scroll) {} + + ngOnInit() { + this._scroll.register(this); + this._elementRef.nativeElement.addEventListener('scroll', (e: Event) => { + this._elementScrolled.next(e); + }); + } + + ngOnDestroy() { + this._scroll.deregister(this); + } + + /** Returns observable that emits an event when the scroll event is fired on the host element. */ + elementScrolled(): Observable { + return this._elementScrolled.asObservable(); + } +} + + +@NgModule({ + exports: [Scrollable], + declarations: [Scrollable], +}) +export class ScrollModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: ScrollModule, + providers: [Scroll] + }; + } +} diff --git a/src/lib/sidenav/sidenav.html b/src/lib/sidenav/sidenav.html index 652ebf250c62..dae02bd1b0e5 100644 --- a/src/lib/sidenav/sidenav.html +++ b/src/lib/sidenav/sidenav.html @@ -3,6 +3,6 @@ -
+
diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts index d433575b487e..2d59377a3458 100644 --- a/src/lib/sidenav/sidenav.ts +++ b/src/lib/sidenav/sidenav.ts @@ -20,6 +20,11 @@ import {Dir, MdError, coerceBooleanProperty, DefaultStyleCompatibilityModeModule import {A11yModule, A11Y_PROVIDERS} 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 {ScrollModule} from '../core/scroll/scrollable'; +import {InteractivityChecker} from '../core/a11y/interactivity-checker'; +import {MdLiveAnnouncer} from '../core/a11y/live-announcer'; +import {Scroll} from '../core/scroll/scroll'; /** Exception thrown when two MdSidenav are matching the same side. */ @@ -503,7 +508,13 @@ export class MdSidenavLayout implements AfterContentInit { @NgModule({ - imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule], + imports: [ + CommonModule, + DefaultStyleCompatibilityModeModule, + A11yModule, + OverlayModule, + ScrollModule + ], exports: [MdSidenavLayout, MdSidenav, DefaultStyleCompatibilityModeModule], declarations: [MdSidenavLayout, MdSidenav], }) @@ -511,7 +522,7 @@ export class MdSidenavModule { static forRoot(): ModuleWithProviders { return { ngModule: MdSidenavModule, - providers: [A11Y_PROVIDERS] + providers: [MdLiveAnnouncer, InteractivityChecker, Scroll] }; } } diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts index c14536da593d..20e6e20e8526 100644 --- a/src/lib/tooltip/tooltip.ts +++ b/src/lib/tooltip/tooltip.ts @@ -13,7 +13,7 @@ import { animate, AnimationTransitionEvent, NgZone, - Optional, + Optional, OnDestroy, OnInit, } from '@angular/core'; import { Overlay, @@ -23,13 +23,16 @@ import { ComponentPortal, OverlayConnectionPosition, OriginConnectionPosition, - OVERLAY_PROVIDERS, 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 {Scroll} from '../core/scroll/scroll'; +import {ScrollModule} from '../core/scroll/scrollable'; +import {OverlayPositionBuilder} from '../core/overlay/position/overlay-position-builder'; +import {ViewportRuler} from '../core/overlay/position/viewport-ruler'; export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after'; @@ -52,7 +55,7 @@ export const TOUCHEND_HIDE_DELAY = 1500; }, exportAs: 'mdTooltip', }) -export class MdTooltip { +export class MdTooltip implements OnInit, OnDestroy { _overlayRef: OverlayRef; _tooltipInstance: TooltipComponent; @@ -92,10 +95,23 @@ export class MdTooltip { } } - constructor(private _overlay: Overlay, private _elementRef: ElementRef, - private _viewContainerRef: ViewContainerRef, private _ngZone: NgZone, + constructor(private _overlay: Overlay, + private _scroll: Scroll, + 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._scroll.scrolled().subscribe(() => { + if (this._overlayRef) { + this._overlayRef.updatePosition(); + } + }); + } + /** Dispose the tooltip when destroyed */ ngOnDestroy() { if (this._tooltipInstance) { @@ -349,7 +365,7 @@ export class TooltipComponent { @NgModule({ - imports: [OverlayModule, DefaultStyleCompatibilityModeModule], + imports: [OverlayModule, DefaultStyleCompatibilityModeModule, ScrollModule], exports: [MdTooltip, TooltipComponent, DefaultStyleCompatibilityModeModule], declarations: [MdTooltip, TooltipComponent], entryComponents: [TooltipComponent], @@ -358,7 +374,12 @@ export class MdTooltipModule { static forRoot(): ModuleWithProviders { return { ngModule: MdTooltipModule, - providers: OVERLAY_PROVIDERS, + providers: [ + Overlay, + OverlayPositionBuilder, + ViewportRuler, + Scroll + ] }; } }