diff --git a/src/cdk/overlay/position/connected-position-strategy.spec.ts b/src/cdk/overlay/position/connected-position-strategy.spec.ts index 0ba80be29cd5..7c2a81476e2a 100644 --- a/src/cdk/overlay/position/connected-position-strategy.spec.ts +++ b/src/cdk/overlay/position/connected-position-strategy.spec.ts @@ -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'; @@ -545,7 +545,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); diff --git a/src/cdk/overlay/position/connected-position-strategy.ts b/src/cdk/overlay/position/connected-position-strategy.ts index cbdf83b30bf8..dd5a9c9a3a65 100644 --- a/src/cdk/overlay/position/connected-position-strategy.ts +++ b/src/cdk/overlay/position/connected-position-strategy.ts @@ -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; @@ -176,7 +176,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; } diff --git a/src/cdk/overlay/scroll/index.ts b/src/cdk/overlay/scroll/index.ts index c9bd2c004dd9..d4dc631d9be7 100644 --- a/src/cdk/overlay/scroll/index.ts +++ b/src/cdk/overlay/scroll/index.ts @@ -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'; diff --git a/src/cdk/scrolling/scroll-dispatcher.spec.ts b/src/cdk/scrolling/scroll-dispatcher.spec.ts index 7e8fc9d1a9d0..2da6f3a342ca 100644 --- a/src/cdk/scrolling/scroll-dispatcher.spec.ts +++ b/src/cdk/scrolling/scroll-dispatcher.spec.ts @@ -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; + 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: `
` }) class ScrollingComponent { - @ViewChild(Scrollable) scrollable: Scrollable; + @ViewChild(CdkScrollable) scrollable: CdkScrollable; @ViewChild('scrollingElement') scrollingElement: ElementRef; } diff --git a/src/cdk/scrolling/scroll-dispatcher.ts b/src/cdk/scrolling/scroll-dispatcher.ts index 95d5c3667450..7b05738feb3a 100644 --- a/src/cdk/scrolling/scroll-dispatcher.ts +++ b/src/cdk/scrolling/scroll-dispatcher.ts @@ -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 = new Subject(); + private _scrolled = new Subject(); /** 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 = new Map(); + scrollContainers: Map = 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 { + scrolled(auditTimeInMs: number = DEFAULT_SCROLL_TIME): Observable { return this._platform.isBrowser ? Observable.create(observer => { if (!this._globalSubscription) { this._addGlobalListener(); @@ -97,12 +99,26 @@ export class ScrollDispatcher { }) : observableOf(); } + /** + * 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 { + const ancestors = this.getAncestorScrollContainers(elementRef); + + return filter.call(this.scrolled(auditTimeInMs), target => { + return !target || ancestors.indexOf(target) > -1; + }); + } + /** Returns all registered Scrollables that contain the provided element. */ - getScrollContainers(elementRef: ElementRef): Scrollable[] { - const scrollingContainers: Scrollable[] = []; + getAncestorScrollContainers(elementRef: ElementRef): CdkScrollable[] { + const scrollingContainers: CdkScrollable[] = []; - this.scrollableReferences.forEach((_subscription: Subscription, scrollable: Scrollable) => { - if (this.scrollableContainsElement(scrollable, elementRef)) { + 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; diff --git a/src/cdk/scrolling/scrollable.ts b/src/cdk/scrolling/scrollable.ts index 2431f9041d08..7c4ff9d55286 100644 --- a/src/cdk/scrolling/scrollable.ts +++ b/src/cdk/scrolling/scrollable.ts @@ -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 = new Subject(); private _scrollListener: Function | null; diff --git a/src/cdk/scrolling/scrolling-module.ts b/src/cdk/scrolling/scrolling-module.ts index a798bbc19816..616f29d07581 100644 --- a/src/cdk/scrolling/scrolling-module.ts +++ b/src/cdk/scrolling/scrolling-module.ts @@ -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 {} diff --git a/src/cdk/testing/dispatch-events.ts b/src/cdk/testing/dispatch-events.ts index c4600947d47d..28413dd74a89 100644 --- a/src/cdk/testing/dispatch-events.ts +++ b/src/cdk/testing/dispatch-events.ts @@ -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. */ diff --git a/src/cdk/testing/event-objects.ts b/src/cdk/testing/event-objects.ts index fce56a683d20..9ead01e767cf 100644 --- a/src/cdk/testing/event-objects.ts +++ b/src/cdk/testing/event-objects.ts @@ -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; } diff --git a/src/lib/tooltip/tooltip.spec.ts b/src/lib/tooltip/tooltip.spec.ts index 61446059f112..bb3e6eb95482 100644 --- a/src/lib/tooltip/tooltip.spec.ts +++ b/src/lib/tooltip/tooltip.spec.ts @@ -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; diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts index d368952ca511..8e2aabc5367e 100644 --- a/src/lib/tooltip/tooltip.ts +++ b/src/lib/tooltip/tooltip.ts @@ -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()) {