refactor(scroll-dispatcher): API improvements (#7630)

* Adds the `ancestorScrolled` method that allows for the consumer to subscribe to scroll events coming from the element's parent scrollables.
* The `scrolled` and `ancestorScrolled` streams now expose the scrollable that was scrolled.

BREAKING CHANGES:
* The `getScrollContainers` method has been renamed to `getAncestorScrollContainers` to better describe what it does.
* The `ScrollDispatcher.scrollableReferences` property has been renamed to `scrollContainers`.
* The `ScrollDispatcher.scrollableContainsElement` method has been removed.
* The `Scrollable` class has been renamed to `CdkScrollable` for consistency.
This commit is contained in:
Kristiyan Kostadinov
2017-10-12 23:39:07 +02:00
committed by Andrew Seguin
parent 80671bf2e8
commit b2fbea7e8a
11 changed files with 94 additions and 48 deletions
@@ -4,7 +4,7 @@ import {ConnectedPositionStrategy} from './connected-position-strategy';
import {ViewportRuler, VIEWPORT_RULER_PROVIDER} from '@angular/cdk/scrolling';
import {OverlayPositionBuilder} from './overlay-position-builder';
import {ConnectedOverlayPositionChange} from './connected-position';
import {Scrollable} from '@angular/cdk/scrolling';
import {CdkScrollable} from '@angular/cdk/scrolling';
import {Subscription} from 'rxjs/Subscription';
import {ScrollDispatchModule} from '@angular/cdk/scrolling';
import {OverlayRef} from '../overlay-ref';
@@ -567,7 +567,7 @@ describe('ConnectedPositionStrategy', () => {
{overlayX: 'start', overlayY: 'top'});
strategy.withScrollableContainers([
new Scrollable(new FakeElementRef(scrollable), null!, null!, null!)]);
new CdkScrollable(new FakeElementRef(scrollable), null!, null!, null!)]);
strategy.attach(fakeOverlayRef(overlayElement));
positionChangeHandler = jasmine.createSpy('positionChangeHandler');
onPositionChangeSubscription = strategy.onPositionChange.subscribe(positionChangeHandler);
@@ -19,7 +19,7 @@ import {
import {Subject} from 'rxjs/Subject';
import {Subscription} from 'rxjs/Subscription';
import {Observable} from 'rxjs/Observable';
import {Scrollable} from '@angular/cdk/scrolling';
import {CdkScrollable} from '@angular/cdk/scrolling';
import {isElementScrolledOutsideView, isElementClippedByScrolling} from './scroll-clip';
import {OverlayRef} from '../overlay-ref';
@@ -46,7 +46,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
private _offsetY: number = 0;
/** The Scrollable containers used to check scrollable view properties on position change. */
private scrollables: Scrollable[] = [];
private scrollables: CdkScrollable[] = [];
/** Subscription to viewport resize events. */
private _resizeSubscription = Subscription.EMPTY;
@@ -181,7 +181,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
* on reposition we can evaluate if it or the overlay has been clipped or outside view. Every
* Scrollable must be an ancestor element of the strategy's origin element.
*/
withScrollableContainers(scrollables: Scrollable[]) {
withScrollableContainers(scrollables: CdkScrollable[]) {
this.scrollables = scrollables;
}
+1 -1
View File
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
export {Scrollable, ScrollDispatcher} from '@angular/cdk/scrolling';
export {CdkScrollable, ScrollDispatcher} from '@angular/cdk/scrolling';
// Export pre-defined scroll strategies and interface to build custom ones.
export {ScrollStrategy} from './scroll-strategy';
+38 -12
View File
@@ -1,6 +1,6 @@
import {inject, TestBed, async, fakeAsync, ComponentFixture, tick} from '@angular/core/testing';
import {NgModule, Component, ViewChild, ElementRef} from '@angular/core';
import {Scrollable, ScrollDispatcher, ScrollDispatchModule} from './public-api';
import {CdkScrollable, ScrollDispatcher, ScrollDispatchModule} from './public-api';
import {dispatchFakeEvent} from '@angular/cdk/testing';
describe('Scroll Dispatcher', () => {
@@ -26,15 +26,15 @@ describe('Scroll Dispatcher', () => {
it('should be registered with the scrollable directive with the scroll service', () => {
const componentScrollable = fixture.componentInstance.scrollable;
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(true);
expect(scroll.scrollContainers.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);
expect(scroll.scrollContainers.has(componentScrollable)).toBe(true);
fixture.destroy();
expect(scroll.scrollableReferences.has(componentScrollable)).toBe(false);
expect(scroll.scrollContainers.has(componentScrollable)).toBe(false);
});
it('should notify through the directive and service that a scroll event occurred',
@@ -52,7 +52,7 @@ describe('Scroll Dispatcher', () => {
// 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.
dispatchFakeEvent(fixture.componentInstance.scrollingElement.nativeElement, 'scroll');
dispatchFakeEvent(fixture.componentInstance.scrollingElement.nativeElement, 'scroll', false);
// The scrollable directive should have notified the service immediately.
expect(directiveSpy).toHaveBeenCalled();
@@ -71,7 +71,7 @@ describe('Scroll Dispatcher', () => {
const subscription = fixture.ngZone!.onUnstable.subscribe(spy);
scroll.scrolled(0).subscribe(() => {});
dispatchFakeEvent(document, 'scroll');
dispatchFakeEvent(document, 'scroll', false);
expect(spy).not.toHaveBeenCalled();
subscription.unsubscribe();
@@ -81,7 +81,7 @@ describe('Scroll Dispatcher', () => {
const spy = jasmine.createSpy('zone unstable callback');
const subscription = fixture.ngZone!.onUnstable.subscribe(spy);
dispatchFakeEvent(fixture.componentInstance.scrollingElement.nativeElement, 'scroll');
dispatchFakeEvent(fixture.componentInstance.scrollingElement.nativeElement, 'scroll', false);
expect(spy).not.toHaveBeenCalled();
subscription.unsubscribe();
@@ -91,11 +91,11 @@ describe('Scroll Dispatcher', () => {
const spy = jasmine.createSpy('global scroll callback');
const subscription = scroll.scrolled(0).subscribe(spy);
dispatchFakeEvent(document, 'scroll');
dispatchFakeEvent(document, 'scroll', false);
expect(spy).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
dispatchFakeEvent(document, 'scroll');
dispatchFakeEvent(document, 'scroll', false);
expect(spy).toHaveBeenCalledTimes(1);
});
@@ -104,22 +104,48 @@ describe('Scroll Dispatcher', () => {
describe('Nested scrollables', () => {
let scroll: ScrollDispatcher;
let fixture: ComponentFixture<NestedScrollingComponent>;
let element: ElementRef;
beforeEach(inject([ScrollDispatcher], (s: ScrollDispatcher) => {
scroll = s;
fixture = TestBed.createComponent(NestedScrollingComponent);
fixture.detectChanges();
element = fixture.componentInstance.interestingElement;
}));
it('should be able to identify the containing scrollables of an element', () => {
const interestingElement = fixture.componentInstance.interestingElement;
const scrollContainers = scroll.getScrollContainers(interestingElement);
const scrollContainers = scroll.getAncestorScrollContainers(element);
const scrollableElementIds =
scrollContainers.map(scrollable => scrollable.getElementRef().nativeElement.id);
expect(scrollableElementIds).toEqual(['scrollable-1', 'scrollable-1a']);
});
it('should emit when one of the ancestor scrollable containers is scrolled', () => {
const spy = jasmine.createSpy('scroll spy');
const subscription = scroll.ancestorScrolled(element, 0).subscribe(spy);
const grandparent = fixture.debugElement.nativeElement.querySelector('#scrollable-1');
dispatchFakeEvent(grandparent, 'scroll', false);
expect(spy).toHaveBeenCalledTimes(1);
dispatchFakeEvent(window.document, 'scroll', false);
expect(spy).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
});
it('should not emit when a non-ancestor is scrolled', () => {
const spy = jasmine.createSpy('scroll spy');
const subscription = scroll.ancestorScrolled(element, 0).subscribe(spy);
const stranger = fixture.debugElement.nativeElement.querySelector('#scrollable-2');
dispatchFakeEvent(stranger, 'scroll', false);
expect(spy).not.toHaveBeenCalled();
subscription.unsubscribe();
});
});
describe('lazy subscription', () => {
@@ -172,7 +198,7 @@ describe('Scroll Dispatcher', () => {
template: `<div #scrollingElement cdk-scrollable style="height: 9999px"></div>`
})
class ScrollingComponent {
@ViewChild(Scrollable) scrollable: Scrollable;
@ViewChild(CdkScrollable) scrollable: CdkScrollable;
@ViewChild('scrollingElement') scrollingElement: ElementRef;
}
+35 -19
View File
@@ -13,8 +13,8 @@ import {Subscription} from 'rxjs/Subscription';
import {Observable} from 'rxjs/Observable';
import {fromEvent} from 'rxjs/observable/fromEvent';
import {of as observableOf} from 'rxjs/observable/of';
import {auditTime} from 'rxjs/operator/auditTime';
import {Scrollable} from './scrollable';
import {auditTime, filter} from '@angular/cdk/rxjs';
import {CdkScrollable} from './scrollable';
/** Time in ms to throttle the scrolling events by default. */
@@ -29,7 +29,7 @@ export class ScrollDispatcher {
constructor(private _ngZone: NgZone, private _platform: Platform) { }
/** Subject for notifying that a registered scrollable reference element has been scrolled. */
private _scrolled: Subject<void> = new Subject<void>();
private _scrolled = new Subject<CdkScrollable|void>();
/** Keeps track of the global `scroll` and `resize` subscriptions. */
_globalSubscription: Subscription | null = null;
@@ -41,28 +41,30 @@ export class ScrollDispatcher {
* Map of all the scrollable references that are registered with the service and their
* scroll event subscriptions.
*/
scrollableReferences: Map<Scrollable, Subscription> = new Map();
scrollContainers: Map<CdkScrollable, Subscription> = new Map();
/**
* 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.
* Registers a scrollable instance with the service and listens for its scrolled events. When the
* scrollable is scrolled, the service emits the event to its scrolled observable.
* @param scrollable Scrollable instance to be registered.
*/
register(scrollable: Scrollable): void {
const scrollSubscription = scrollable.elementScrolled().subscribe(() => this._scrolled.next());
this.scrollableReferences.set(scrollable, scrollSubscription);
register(scrollable: CdkScrollable): void {
const scrollSubscription = scrollable.elementScrolled()
.subscribe(() => this._scrolled.next(scrollable));
this.scrollContainers.set(scrollable, scrollSubscription);
}
/**
* Deregisters a Scrollable reference and unsubscribes from its scroll event observable.
* @param scrollable Scrollable instance to be deregistered.
*/
deregister(scrollable: Scrollable): void {
const scrollableReference = this.scrollableReferences.get(scrollable);
deregister(scrollable: CdkScrollable): void {
const scrollableReference = this.scrollContainers.get(scrollable);
if (scrollableReference) {
scrollableReference.unsubscribe();
this.scrollableReferences.delete(scrollable);
this.scrollContainers.delete(scrollable);
}
}
@@ -71,7 +73,7 @@ export class ScrollDispatcher {
* references (or window, document, or body) fire a scrolled event. Can provide a time in ms
* to override the default "throttle" time.
*/
scrolled(auditTimeInMs: number = DEFAULT_SCROLL_TIME): Observable<void> {
scrolled(auditTimeInMs: number = DEFAULT_SCROLL_TIME): Observable<CdkScrollable|void> {
return this._platform.isBrowser ? Observable.create(observer => {
if (!this._globalSubscription) {
this._addGlobalListener();
@@ -97,12 +99,26 @@ export class ScrollDispatcher {
}) : observableOf<void>();
}
/** Returns all registered Scrollables that contain the provided element. */
getScrollContainers(elementRef: ElementRef): Scrollable[] {
const scrollingContainers: Scrollable[] = [];
/**
* Returns an observable that emits whenever any of the
* scrollable ancestors of an element are scrolled.
* @param elementRef Element whose ancestors to listen for.
* @param auditTimeInMs Time to throttle the scroll events.
*/
ancestorScrolled(elementRef: ElementRef, auditTimeInMs?: number): Observable<CdkScrollable> {
const ancestors = this.getAncestorScrollContainers(elementRef);
this.scrollableReferences.forEach((_subscription: Subscription, scrollable: Scrollable) => {
if (this.scrollableContainsElement(scrollable, elementRef)) {
return filter.call(this.scrolled(auditTimeInMs), target => {
return !target || ancestors.indexOf(target) > -1;
});
}
/** Returns all registered Scrollables that contain the provided element. */
getAncestorScrollContainers(elementRef: ElementRef): CdkScrollable[] {
const scrollingContainers: CdkScrollable[] = [];
this.scrollContainers.forEach((_subscription: Subscription, scrollable: CdkScrollable) => {
if (this._scrollableContainsElement(scrollable, elementRef)) {
scrollingContainers.push(scrollable);
}
});
@@ -111,7 +127,7 @@ export class ScrollDispatcher {
}
/** Returns true if the element is contained within the provided Scrollable. */
scrollableContainsElement(scrollable: Scrollable, elementRef: ElementRef): boolean {
private _scrollableContainsElement(scrollable: CdkScrollable, elementRef: ElementRef): boolean {
let element = elementRef.nativeElement;
let scrollableElement = scrollable.getElementRef().nativeElement;
+1 -1
View File
@@ -20,7 +20,7 @@ import {ScrollDispatcher} from './scroll-dispatcher';
@Directive({
selector: '[cdk-scrollable], [cdkScrollable]'
})
export class Scrollable implements OnInit, OnDestroy {
export class CdkScrollable implements OnInit, OnDestroy {
private _elementScrolled: Subject<Event> = new Subject();
private _scrollListener: Function | null;
+3 -3
View File
@@ -8,13 +8,13 @@
import {NgModule} from '@angular/core';
import {SCROLL_DISPATCHER_PROVIDER} from './scroll-dispatcher';
import {Scrollable} from './scrollable';
import {CdkScrollable} from './scrollable';
import {PlatformModule} from '@angular/cdk/platform';
@NgModule({
imports: [PlatformModule],
exports: [Scrollable],
declarations: [Scrollable],
exports: [CdkScrollable],
declarations: [CdkScrollable],
providers: [SCROLL_DISPATCHER_PROVIDER],
})
export class ScrollDispatchModule {}
+2 -2
View File
@@ -20,8 +20,8 @@ export function dispatchEvent(node: Node | Window, event: Event): Event {
}
/** Shorthand to dispatch a fake event on a specified node. */
export function dispatchFakeEvent(node: Node | Window, type: string): Event {
return dispatchEvent(node, createFakeEvent(type));
export function dispatchFakeEvent(node: Node | Window, type: string, canBubble?: boolean): Event {
return dispatchEvent(node, createFakeEvent(type, canBubble));
}
/** Shorthand to dispatch a keyboard event with a specified key code. */
+2 -2
View File
@@ -74,8 +74,8 @@ export function createKeyboardEvent(type: string, keyCode: number, target?: Elem
}
/** Creates a fake event object with any desired event type. */
export function createFakeEvent(type: string) {
export function createFakeEvent(type: string, canBubble = true, cancelable = true) {
const event = document.createEvent('Event');
event.initEvent(type, true, true);
event.initEvent(type, canBubble, cancelable);
return event;
}
+2 -2
View File
@@ -17,7 +17,7 @@ import {AnimationEvent} from '@angular/animations';
import {By} from '@angular/platform-browser';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {OverlayContainer, OverlayModule, Scrollable} from '@angular/cdk/overlay';
import {OverlayContainer, OverlayModule, CdkScrollable} from '@angular/cdk/overlay';
import {Platform} from '@angular/cdk/platform';
import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing';
import {ESCAPE} from '@angular/cdk/keycodes';
@@ -686,7 +686,7 @@ class ScrollableTooltipDemo {
message: string = initialTooltipMessage;
showButton: boolean = true;
@ViewChild(Scrollable) scrollingContainer: Scrollable;
@ViewChild(CdkScrollable) scrollingContainer: CdkScrollable;
scrollDown() {
const scrollingContainerEl = this.scrollingContainer.getElementRef().nativeElement;
+5 -1
View File
@@ -280,7 +280,11 @@ export class MatTooltip implements OnDestroy {
.connectedTo(this._elementRef, origin.main, overlay.main)
.withFallbackPosition(origin.fallback, overlay.fallback);
strategy.withScrollableContainers(this._scrollDispatcher.getScrollContainers(this._elementRef));
const scrollableAncestors = this._scrollDispatcher
.getAncestorScrollContainers(this._elementRef);
strategy.withScrollableContainers(scrollableAncestors);
strategy.onPositionChange.subscribe(change => {
if (this._tooltipInstance) {
if (change.scrollableViewProperties.isOverlayClipped && this._tooltipInstance.isVisible()) {