Skip to content

Commit 9b68e68

Browse files
andrewseguinjelbourn
authored andcommitted
feat(scroll): provide directive and service to listen to scrolling (#2188)
* feat(scroll): provide directive and service to listen to scrolling * review response * fix lint * move scroll to overlay * rename scroll in tooltip * remove reference tot he live announcer
1 parent a1f9028 commit 9b68e68

File tree

10 files changed

+229
-15
lines changed

10 files changed

+229
-15
lines changed

src/lib/core/core.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export {
4747
} from './overlay/overlay-directives';
4848
export * from './overlay/position/connected-position-strategy';
4949
export * from './overlay/position/connected-position';
50+
export {ScrollDispatcher} from './overlay/scroll/scroll-dispatcher';
5051

5152
// Gestures
5253
export {GestureConfig} from './gestures/gesture-config';
@@ -110,8 +111,22 @@ export {NoConflictStyleCompatibilityMode} from './compatibility/no-conflict-mode
110111

111112

112113
@NgModule({
113-
imports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
114-
exports: [MdLineModule, RtlModule, MdRippleModule, PortalModule, OverlayModule, A11yModule],
114+
imports: [
115+
MdLineModule,
116+
RtlModule,
117+
MdRippleModule,
118+
PortalModule,
119+
OverlayModule,
120+
A11yModule,
121+
],
122+
exports: [
123+
MdLineModule,
124+
RtlModule,
125+
MdRippleModule,
126+
PortalModule,
127+
OverlayModule,
128+
A11yModule,
129+
],
115130
})
116131
export class MdCoreModule {
117132
static forRoot(): ModuleWithProviders {

src/lib/core/overlay/overlay-directives.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {PortalModule} from '../portal/portal-directives';
2323
import {ConnectedPositionStrategy} from './position/connected-position-strategy';
2424
import {Subscription} from 'rxjs/Subscription';
2525
import {Dir, LayoutDirection} from '../rtl/dir';
26+
import {Scrollable} from './scroll/scrollable';
2627

2728
/** Default set of positions for the overlay. Follows the behavior of a dropdown. */
2829
let defaultPositionList = [
@@ -285,8 +286,8 @@ export class ConnectedOverlayDirective implements OnDestroy {
285286

286287
@NgModule({
287288
imports: [PortalModule],
288-
exports: [ConnectedOverlayDirective, OverlayOrigin],
289-
declarations: [ConnectedOverlayDirective, OverlayOrigin],
289+
exports: [ConnectedOverlayDirective, OverlayOrigin, Scrollable],
290+
declarations: [ConnectedOverlayDirective, OverlayOrigin, Scrollable],
290291
})
291292
export class OverlayModule {
292293
static forRoot(): ModuleWithProviders {

src/lib/core/overlay/overlay.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {OverlayRef} from './overlay-ref';
1111
import {OverlayPositionBuilder} from './position/overlay-position-builder';
1212
import {ViewportRuler} from './position/viewport-ruler';
1313
import {OverlayContainer} from './overlay-container';
14+
import {ScrollDispatcher} from './scroll/scroll-dispatcher';
1415

1516
/** Next overlay unique ID. */
1617
let nextUniqueId = 0;
@@ -93,4 +94,5 @@ export const OVERLAY_PROVIDERS = [
9394
OverlayPositionBuilder,
9495
Overlay,
9596
OverlayContainer,
97+
ScrollDispatcher,
9698
];
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {inject, TestBed, async, ComponentFixture} from '@angular/core/testing';
2+
import {NgModule, Component, ViewChild, ElementRef} from '@angular/core';
3+
import {ScrollDispatcher} from './scroll-dispatcher';
4+
import {OverlayModule} from '../overlay-directives';
5+
import {Scrollable} from './scrollable';
6+
7+
describe('Scroll Dispatcher', () => {
8+
let scroll: ScrollDispatcher;
9+
let fixture: ComponentFixture<ScrollingComponent>;
10+
11+
beforeEach(async(() => {
12+
TestBed.configureTestingModule({
13+
imports: [OverlayModule.forRoot(), ScrollTestModule],
14+
});
15+
16+
TestBed.compileComponents();
17+
}));
18+
19+
beforeEach(inject([ScrollDispatcher], (s: ScrollDispatcher) => {
20+
scroll = s;
21+
22+
fixture = TestBed.createComponent(ScrollingComponent);
23+
fixture.detectChanges();
24+
}));
25+
26+
it('should be registered with the scrollable directive with the scroll service', () => {
27+
const componentScrollable = fixture.componentInstance.scrollable;
28+
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true);
29+
});
30+
31+
it('should have the scrollable directive deregistered when the component is destroyed', () => {
32+
const componentScrollable = fixture.componentInstance.scrollable;
33+
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true);
34+
35+
fixture.destroy();
36+
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(false);
37+
});
38+
39+
it('should notify through the directive and service that a scroll event occurred', () => {
40+
let hasDirectiveScrollNotified = false;
41+
// Listen for notifications from scroll directive
42+
let scrollable = fixture.componentInstance.scrollable;
43+
scrollable.elementScrolled().subscribe(() => { hasDirectiveScrollNotified = true; });
44+
45+
// Listen for notifications from scroll service
46+
let hasServiceScrollNotified = false;
47+
scroll.scrolled().subscribe(() => { hasServiceScrollNotified = true; });
48+
49+
// Emit a scroll event from the scrolling element in our component.
50+
// This event should be picked up by the scrollable directive and notify.
51+
// The notification should be picked up by the service.
52+
const scrollEvent = document.createEvent('UIEvents');
53+
scrollEvent.initUIEvent('scroll', true, true, window, 0);
54+
fixture.componentInstance.scrollingElement.nativeElement.dispatchEvent(scrollEvent);
55+
56+
expect(hasDirectiveScrollNotified).toBe(true);
57+
expect(hasServiceScrollNotified).toBe(true);
58+
});
59+
});
60+
61+
62+
/** Simple component that contains a large div and can be scrolled. */
63+
@Component({
64+
template: `<div #scrollingElement cdk-scrollable style="height: 9999px"></div>`
65+
})
66+
class ScrollingComponent {
67+
@ViewChild(Scrollable) scrollable: Scrollable;
68+
@ViewChild('scrollingElement') scrollingElement: ElementRef;
69+
}
70+
71+
const TEST_COMPONENTS = [ScrollingComponent];
72+
@NgModule({
73+
imports: [OverlayModule],
74+
providers: [ScrollDispatcher],
75+
exports: TEST_COMPONENTS,
76+
declarations: TEST_COMPONENTS,
77+
entryComponents: TEST_COMPONENTS,
78+
})
79+
class ScrollTestModule { }
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {Injectable} from '@angular/core';
2+
import {Scrollable} from './scrollable';
3+
import {Subject} from 'rxjs/Subject';
4+
import {Observable} from 'rxjs/Observable';
5+
import {Subscription} from 'rxjs/Subscription';
6+
import 'rxjs/add/observable/fromEvent';
7+
8+
9+
/**
10+
* Service contained all registered Scrollable references and emits an event when any one of the
11+
* Scrollable references emit a scrolled event.
12+
*/
13+
@Injectable()
14+
export class ScrollDispatcher {
15+
/** Subject for notifying that a registered scrollable reference element has been scrolled. */
16+
_scrolled: Subject<void> = new Subject<void>();
17+
18+
/**
19+
* Map of all the scrollable references that are registered with the service and their
20+
* scroll event subscriptions.
21+
*/
22+
scrollableReferences: WeakMap<Scrollable, Subscription> = new WeakMap();
23+
24+
constructor() {
25+
// By default, notify a scroll event when the document is scrolled or the window is resized.
26+
Observable.fromEvent(window.document, 'scroll').subscribe(() => this._notify());
27+
Observable.fromEvent(window, 'resize').subscribe(() => this._notify());
28+
}
29+
30+
/**
31+
* Registers a Scrollable with the service and listens for its scrolled events. When the
32+
* scrollable is scrolled, the service emits the event in its scrolled observable.
33+
*/
34+
register(scrollable: Scrollable): void {
35+
const scrollSubscription = scrollable.elementScrolled().subscribe(() => this._notify());
36+
this.scrollableReferences.set(scrollable, scrollSubscription);
37+
}
38+
39+
/**
40+
* Deregisters a Scrollable reference and unsubscribes from its scroll event observable.
41+
*/
42+
deregister(scrollable: Scrollable): void {
43+
this.scrollableReferences.get(scrollable).unsubscribe();
44+
this.scrollableReferences.delete(scrollable);
45+
}
46+
47+
/**
48+
* Returns an observable that emits an event whenever any of the registered Scrollable
49+
* references (or window, document, or body) fire a scrolled event.
50+
* TODO: Add an event limiter that includes throttle with the leading and trailing events.
51+
*/
52+
scrolled(): Observable<void> {
53+
return this._scrolled.asObservable();
54+
}
55+
56+
/** Sends a notification that a scroll event has been fired. */
57+
_notify() {
58+
this._scrolled.next();
59+
}
60+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
Directive, ElementRef, OnInit, OnDestroy
3+
} from '@angular/core';
4+
import {Observable} from 'rxjs/Observable';
5+
import {ScrollDispatcher} from './scroll-dispatcher';
6+
import 'rxjs/add/observable/fromEvent';
7+
8+
9+
/**
10+
* Sends an event when the directive's element is scrolled. Registers itself with the
11+
* ScrollDispatcher service to include itself as part of its collection of scrolling events that it
12+
* can be listened to through the service.
13+
*/
14+
@Directive({
15+
selector: '[cdk-scrollable]'
16+
})
17+
export class Scrollable implements OnInit, OnDestroy {
18+
constructor(private _elementRef: ElementRef, private _scroll: ScrollDispatcher) {}
19+
20+
ngOnInit() {
21+
this._scroll.register(this);
22+
}
23+
24+
ngOnDestroy() {
25+
this._scroll.deregister(this);
26+
}
27+
28+
/** Returns observable that emits when the scroll event is fired on the host element. */
29+
elementScrolled(): Observable<any> {
30+
return Observable.fromEvent(this._elementRef.nativeElement, 'scroll');
31+
}
32+
}

src/lib/sidenav/sidenav-container.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33

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

6-
<div class="md-sidenav-content" [ngStyle]="_getStyles()">
6+
<div class="md-sidenav-content" [ngStyle]="_getStyles()" cdk-scrollable>
77
<ng-content></ng-content>
88
</div>

src/lib/sidenav/sidenav.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ import {
1717
} from '@angular/core';
1818
import {CommonModule} from '@angular/common';
1919
import {Dir, MdError, coerceBooleanProperty, DefaultStyleCompatibilityModeModule} from '../core';
20-
import {A11yModule, A11Y_PROVIDERS} from '../core/a11y/index';
20+
import {A11yModule} from '../core/a11y/index';
2121
import {FocusTrap} from '../core/a11y/focus-trap';
2222
import {ESCAPE} from '../core/keyboard/keycodes';
23+
import {OverlayModule} from '../core/overlay/overlay-directives';
24+
import {InteractivityChecker} from '../core/a11y/interactivity-checker';
25+
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
2326

2427

2528
/** Exception thrown when two MdSidenav are matching the same side. */
@@ -503,15 +506,15 @@ export class MdSidenavContainer implements AfterContentInit {
503506

504507

505508
@NgModule({
506-
imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule],
509+
imports: [CommonModule, DefaultStyleCompatibilityModeModule, A11yModule, OverlayModule],
507510
exports: [MdSidenavContainer, MdSidenav, DefaultStyleCompatibilityModeModule],
508511
declarations: [MdSidenavContainer, MdSidenav],
509512
})
510513
export class MdSidenavModule {
511514
static forRoot(): ModuleWithProviders {
512515
return {
513516
ngModule: MdSidenavModule,
514-
providers: [A11Y_PROVIDERS]
517+
providers: [InteractivityChecker, ScrollDispatcher]
515518
};
516519
}
517520
}

src/lib/tooltip/tooltip.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
AnimationTransitionEvent,
1515
NgZone,
1616
Optional,
17+
OnDestroy,
18+
OnInit
1719
} from '@angular/core';
1820
import {
1921
Overlay,
@@ -23,16 +25,17 @@ import {
2325
ComponentPortal,
2426
OverlayConnectionPosition,
2527
OriginConnectionPosition,
26-
OVERLAY_PROVIDERS,
27-
DefaultStyleCompatibilityModeModule,
28+
DefaultStyleCompatibilityModeModule
2829
} from '../core';
2930
import {MdTooltipInvalidPositionError} from './tooltip-errors';
3031
import {Observable} from 'rxjs/Observable';
3132
import {Subject} from 'rxjs/Subject';
3233
import {Dir} from '../core/rtl/dir';
34+
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
35+
import {OverlayPositionBuilder} from '../core/overlay/position/overlay-position-builder';
36+
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
3337
import 'rxjs/add/operator/first';
3438

35-
3639
export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after';
3740

3841
/** Time in ms to delay before changing the tooltip visibility to hidden */
@@ -54,7 +57,7 @@ export const TOUCHEND_HIDE_DELAY = 1500;
5457
},
5558
exportAs: 'mdTooltip',
5659
})
57-
export class MdTooltip {
60+
export class MdTooltip implements OnInit, OnDestroy {
5861
_overlayRef: OverlayRef;
5962
_tooltipInstance: TooltipComponent;
6063

@@ -104,10 +107,23 @@ export class MdTooltip {
104107
get _deprecatedMessage(): string { return this.message; }
105108
set _deprecatedMessage(v: string) { this.message = v; }
106109

107-
constructor(private _overlay: Overlay, private _elementRef: ElementRef,
108-
private _viewContainerRef: ViewContainerRef, private _ngZone: NgZone,
110+
constructor(private _overlay: Overlay,
111+
private _scrollDispatcher: ScrollDispatcher,
112+
private _elementRef: ElementRef,
113+
private _viewContainerRef: ViewContainerRef,
114+
private _ngZone: NgZone,
109115
@Optional() private _dir: Dir) {}
110116

117+
ngOnInit() {
118+
// When a scroll on the page occurs, update the position in case this tooltip needs
119+
// to be repositioned.
120+
this._scrollDispatcher.scrolled().subscribe(() => {
121+
if (this._overlayRef) {
122+
this._overlayRef.updatePosition();
123+
}
124+
});
125+
}
126+
111127
/** Dispose the tooltip when destroyed */
112128
ngOnDestroy() {
113129
if (this._tooltipInstance) {
@@ -370,7 +386,12 @@ export class MdTooltipModule {
370386
static forRoot(): ModuleWithProviders {
371387
return {
372388
ngModule: MdTooltipModule,
373-
providers: OVERLAY_PROVIDERS,
389+
providers: [
390+
Overlay,
391+
OverlayPositionBuilder,
392+
ViewportRuler,
393+
ScrollDispatcher
394+
]
374395
};
375396
}
376397
}

tools/gulp/tasks/components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ task(':build:components:rollup', () => {
6969

7070
// Rxjs dependencies
7171
'rxjs/Subject': 'Rx',
72+
'rxjs/add/observable/fromEvent': 'Rx.Observable',
7273
'rxjs/add/observable/forkJoin': 'Rx.Observable',
7374
'rxjs/add/observable/of': 'Rx.Observable',
7475
'rxjs/add/operator/toPromise': 'Rx.Observable.prototype',

0 commit comments

Comments
 (0)