Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(scroll): provide directive and service to listen to scrolling #2188

Merged
merged 6 commits into from
Dec 20, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/core/overlay/overlay-directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/core/overlay/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,4 +94,5 @@ export const OVERLAY_PROVIDERS = [
OverlayPositionBuilder,
Overlay,
OverlayContainer,
ScrollDispatcher,
];
79 changes: 79 additions & 0 deletions src/lib/core/overlay/scroll/scroll-dispatcher.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ScrollingComponent>;

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: `<div #scrollingElement cdk-scrollable style="height: 9999px"></div>`
})
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 { }
60 changes: 60 additions & 0 deletions src/lib/core/overlay/scroll/scroll-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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<void> = new Subject<void>();

/**
* Map of all the scrollable references that are registered with the service and their
* scroll event subscriptions.
*/
scrollableReferences: WeakMap<Scrollable, Subscription> = 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<void> {
return this._scrolled.asObservable();
}

/** Sends a notification that a scroll event has been fired. */
_notify() {
this._scrolled.next();
}
}
32 changes: 32 additions & 0 deletions src/lib/core/overlay/scroll/scrollable.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
return Observable.fromEvent(this._elementRef.nativeElement, 'scroll');
}
}
2 changes: 1 addition & 1 deletion src/lib/sidenav/sidenav-container.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@

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

<div class="md-sidenav-content" [ngStyle]="_getStyles()">
<div class="md-sidenav-content" [ngStyle]="_getStyles()" cdk-scrollable>
<ng-content></ng-content>
</div>
9 changes: 6 additions & 3 deletions src/lib/sidenav/sidenav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -503,15 +506,15 @@ export class MdSidenavContainer implements AfterContentInit {


@NgModule({
imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule],
imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule, OverlayModule],
exports: [MdSidenavContainer, MdSidenav, DefaultStyleCompatibilityModeModule],
declarations: [MdSidenavContainer, MdSidenav],
})
export class MdSidenavModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: MdSidenavModule,
providers: [A11Y_PROVIDERS]
providers: [InteractivityChecker, ScrollDispatcher]
};
}
}
35 changes: 28 additions & 7 deletions src/lib/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
AnimationTransitionEvent,
NgZone,
Optional,
OnDestroy,
OnInit
} from '@angular/core';
import {
Overlay,
Expand All @@ -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 */
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -370,7 +386,12 @@ export class MdTooltipModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: MdTooltipModule,
providers: OVERLAY_PROVIDERS,
providers: [
Overlay,
OverlayPositionBuilder,
ViewportRuler,
ScrollDispatcher
]
};
}
}
1 change: 1 addition & 0 deletions tools/gulp/tasks/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down