From c8ec9814bcd2e3087dbb778cec7265296b528890 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 3 May 2017 01:38:24 +0200 Subject: [PATCH] feat(overlay): add scroll handling strategies (#4293) * feat(overlay): add scroll handling strategies * Adds the `scrollStrategy` option to the overlay state, allowing the consumer to specify what scroll handling strategy they'd want to use. Also includes a `ScrollStrategy` interface that users can utilize to build their own strategies. * Adds the `RepositionScrollStrategy`, `CloseScrollStrategy` and `NoopScrollStrategy` as initial, out-of-the-box strategies. * Sets the `RepositionScrollStrategy` by default on all the connected overlays and removes some repetitive logic from the tooltip, autocomplete, menu and select. **Note:** I'll add a `BlockScrollStrategy` in a follow-up PR. I wanted to keep this one shorter. Relates to #4093. * fix: missing types on the scroll dispatcher * refactor: use class for fake scroll strategy * refactor: add onAttached and onDetached observables * chore: rename observables --- src/lib/autocomplete/autocomplete-trigger.ts | 17 +--- src/lib/core/core.ts | 4 + src/lib/core/overlay/overlay-directives.ts | 8 ++ src/lib/core/overlay/overlay-ref.ts | 26 ++++- src/lib/core/overlay/overlay-state.ts | 5 + src/lib/core/overlay/overlay.spec.ts | 98 +++++++++++++++++++ src/lib/core/overlay/overlay.ts | 2 +- .../scroll/close-scroll-strategy.spec.ts | 78 +++++++++++++++ .../overlay/scroll/close-scroll-strategy.ts | 38 +++++++ .../overlay/scroll/noop-scroll-strategy.ts | 10 ++ .../scroll/reposition-scroll-strategy.spec.ts | 91 +++++++++++++++++ .../scroll/reposition-scroll-strategy.ts | 34 +++++++ .../core/overlay/scroll/scroll-strategy.ts | 11 +++ src/lib/menu/menu-trigger.ts | 6 +- src/lib/select/select.ts | 15 +-- src/lib/tooltip/tooltip.spec.ts | 8 -- src/lib/tooltip/tooltip.ts | 24 +---- 17 files changed, 416 insertions(+), 59 deletions(-) create mode 100644 src/lib/core/overlay/scroll/close-scroll-strategy.spec.ts create mode 100644 src/lib/core/overlay/scroll/close-scroll-strategy.ts create mode 100644 src/lib/core/overlay/scroll/noop-scroll-strategy.ts create mode 100644 src/lib/core/overlay/scroll/reposition-scroll-strategy.spec.ts create mode 100644 src/lib/core/overlay/scroll/reposition-scroll-strategy.ts create mode 100644 src/lib/core/overlay/scroll/scroll-strategy.ts diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index 391505afb55f..74c174aa3b71 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -13,7 +13,7 @@ import { } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {DOCUMENT} from '@angular/platform-browser'; -import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core'; +import {Overlay, OverlayRef, OverlayState, TemplatePortal, RepositionScrollStrategy} from '../core'; import {MdAutocomplete} from './autocomplete'; import {PositionStrategy} from '../core/overlay/position/position-strategy'; import {ConnectedPositionStrategy} from '../core/overlay/position/connected-position-strategy'; @@ -76,9 +76,6 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { /** The subscription to positioning changes in the autocomplete panel. */ private _panelPositionSubscription: Subscription; - /** Subscription to global scroll events. */ - private _scrollSubscription: Subscription; - /** Strategy that is used to position the panel. */ private _positionStrategy: ConnectedPositionStrategy; @@ -139,12 +136,6 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { this._subscribeToClosingActions(); } - if (!this._scrollSubscription) { - this._scrollSubscription = this._scrollDispatcher.scrolled(0, () => { - this._overlayRef.updatePosition(); - }); - } - this.autocomplete._setVisibility(); this._floatPlaceholder(); this._panelOpen = true; @@ -156,11 +147,6 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { this._overlayRef.detach(); } - if (this._scrollSubscription) { - this._scrollSubscription.unsubscribe(); - this._scrollSubscription = null; - } - this._panelOpen = false; this._resetPlaceholder(); @@ -374,6 +360,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { overlayState.positionStrategy = this._getOverlayPosition(); overlayState.width = this._getHostWidth(); overlayState.direction = this._dir ? this._dir.value : 'ltr'; + overlayState.scrollStrategy = new RepositionScrollStrategy(this._scrollDispatcher); return overlayState; } diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 206d3411aa59..302bdff76340 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -51,6 +51,10 @@ export * from './overlay/position/global-position-strategy'; export * from './overlay/position/connected-position-strategy'; export * from './overlay/position/connected-position'; export {ScrollDispatcher} from './overlay/scroll/scroll-dispatcher'; +export {ScrollStrategy} from './overlay/scroll/scroll-strategy'; +export {RepositionScrollStrategy} from './overlay/scroll/reposition-scroll-strategy'; +export {CloseScrollStrategy} from './overlay/scroll/close-scroll-strategy'; +export {NoopScrollStrategy} from './overlay/scroll/noop-scroll-strategy'; // Gestures export {GestureConfig} from './gestures/gesture-config'; diff --git a/src/lib/core/overlay/overlay-directives.ts b/src/lib/core/overlay/overlay-directives.ts index ec185339e9d9..b3ab073e02e6 100644 --- a/src/lib/core/overlay/overlay-directives.ts +++ b/src/lib/core/overlay/overlay-directives.ts @@ -23,8 +23,11 @@ import {PortalModule} from '../portal/portal-directives'; import {ConnectedPositionStrategy} from './position/connected-position-strategy'; import {Dir, LayoutDirection} from '../rtl/dir'; import {Scrollable} from './scroll/scrollable'; +import {RepositionScrollStrategy} from './scroll/reposition-scroll-strategy'; +import {ScrollStrategy} from './scroll/scroll-strategy'; import {coerceBooleanProperty} from '../coercion/boolean-property'; import {ESCAPE} from '../keyboard/keycodes'; +import {ScrollDispatcher} from './scroll/scroll-dispatcher'; import {Subscription} from 'rxjs/Subscription'; @@ -119,6 +122,9 @@ export class ConnectedOverlayDirective implements OnDestroy { /** The custom class to be set on the backdrop element. */ @Input() backdropClass: string; + /** Strategy to be used when handling scroll events while the overlay is open. */ + @Input() scrollStrategy: ScrollStrategy = new RepositionScrollStrategy(this._scrollDispatcher); + /** Whether or not the overlay should attach a backdrop. */ @Input() get hasBackdrop() { @@ -156,6 +162,7 @@ export class ConnectedOverlayDirective implements OnDestroy { constructor( private _overlay: Overlay, private _renderer: Renderer2, + private _scrollDispatcher: ScrollDispatcher, templateRef: TemplateRef, viewContainerRef: ViewContainerRef, @Optional() private _dir: Dir) { @@ -213,6 +220,7 @@ export class ConnectedOverlayDirective implements OnDestroy { this._position = this._createPositionStrategy() as ConnectedPositionStrategy; overlayConfig.positionStrategy = this._position; + overlayConfig.scrollStrategy = this.scrollStrategy; return overlayConfig; } diff --git a/src/lib/core/overlay/overlay-ref.ts b/src/lib/core/overlay/overlay-ref.ts index 454ece6dbb3d..00bc206aeaa1 100644 --- a/src/lib/core/overlay/overlay-ref.ts +++ b/src/lib/core/overlay/overlay-ref.ts @@ -1,6 +1,7 @@ import {NgZone} from '@angular/core'; import {PortalHost, Portal} from '../portal/portal'; import {OverlayState} from './overlay-state'; +import {ScrollStrategy} from './scroll/scroll-strategy'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; @@ -12,12 +13,17 @@ import {Subject} from 'rxjs/Subject'; export class OverlayRef implements PortalHost { private _backdropElement: HTMLElement = null; private _backdropClick: Subject = new Subject(); + private _attachments = new Subject(); + private _detachments = new Subject(); constructor( private _portalHost: PortalHost, private _pane: HTMLElement, private _state: OverlayState, - private _ngZone: NgZone) { } + private _ngZone: NgZone) { + + this._state.scrollStrategy.attach(this); + } /** The overlay's HTML element */ get overlayElement(): HTMLElement { @@ -37,6 +43,8 @@ export class OverlayRef implements PortalHost { this.updateSize(); this.updateDirection(); this.updatePosition(); + this._attachments.next(); + this._state.scrollStrategy.enable(); // Enable pointer events for the overlay pane element. this._togglePointerEvents(true); @@ -59,6 +67,8 @@ export class OverlayRef implements PortalHost { // This is necessary because otherwise the pane element will cover the page and disable // pointer events therefore. Depends on the position strategy and the applied pane boundaries. this._togglePointerEvents(false); + this._state.scrollStrategy.disable(); + this._detachments.next(); return this._portalHost.detach(); } @@ -73,6 +83,10 @@ export class OverlayRef implements PortalHost { this.detachBackdrop(); this._portalHost.dispose(); + this._state.scrollStrategy.disable(); + this._detachments.next(); + this._detachments.complete(); + this._attachments.complete(); } /** @@ -89,6 +103,16 @@ export class OverlayRef implements PortalHost { return this._backdropClick.asObservable(); } + /** Returns an observable that emits when the overlay has been attached. */ + attachments(): Observable { + return this._attachments.asObservable(); + } + + /** Returns an observable that emits when the overlay has been detached. */ + detachments(): Observable { + return this._detachments.asObservable(); + } + /** * Gets the current state config of the overlay. */ diff --git a/src/lib/core/overlay/overlay-state.ts b/src/lib/core/overlay/overlay-state.ts index 09676cce9ede..6f397657ae62 100644 --- a/src/lib/core/overlay/overlay-state.ts +++ b/src/lib/core/overlay/overlay-state.ts @@ -1,5 +1,7 @@ import {PositionStrategy} from './position/position-strategy'; import {LayoutDirection} from '../rtl/dir'; +import {ScrollStrategy} from './scroll/scroll-strategy'; +import {NoopScrollStrategy} from './scroll/noop-scroll-strategy'; /** @@ -10,6 +12,9 @@ export class OverlayState { /** Strategy with which to position the overlay. */ positionStrategy: PositionStrategy; + /** Strategy to be used when handling scroll events while the overlay is open. */ + scrollStrategy: ScrollStrategy = new NoopScrollStrategy(); + /** Whether the overlay has a backdrop. */ hasBackdrop: boolean = false; diff --git a/src/lib/core/overlay/overlay.spec.ts b/src/lib/core/overlay/overlay.spec.ts index c5bd1c0bf2c5..46f5cda36227 100644 --- a/src/lib/core/overlay/overlay.spec.ts +++ b/src/lib/core/overlay/overlay.spec.ts @@ -5,8 +5,10 @@ import {TemplatePortal, ComponentPortal} from '../portal/portal'; import {Overlay} from './overlay'; import {OverlayContainer} from './overlay-container'; import {OverlayState} from './overlay-state'; +import {OverlayRef} from './overlay-ref'; import {PositionStrategy} from './position/position-strategy'; import {OverlayModule} from './overlay-directives'; +import {ScrollStrategy} from './scroll/scroll-strategy'; describe('Overlay', () => { @@ -135,6 +137,44 @@ describe('Overlay', () => { expect(pane.getAttribute('dir')).toEqual('rtl'); }); + it('should emit when an overlay is attached', () => { + let overlayRef = overlay.create(); + let spy = jasmine.createSpy('attachments spy'); + + overlayRef.attachments().subscribe(spy); + overlayRef.attach(componentPortal); + + expect(spy).toHaveBeenCalled(); + }); + + it('should emit when an overlay is detached', () => { + let overlayRef = overlay.create(); + let spy = jasmine.createSpy('detachments spy'); + + overlayRef.detachments().subscribe(spy); + overlayRef.attach(componentPortal); + overlayRef.detach(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should emit and complete the observables when an overlay is disposed', () => { + let overlayRef = overlay.create(); + let disposeSpy = jasmine.createSpy('dispose spy'); + let attachCompleteSpy = jasmine.createSpy('attachCompleteSpy spy'); + let detachCompleteSpy = jasmine.createSpy('detachCompleteSpy spy'); + + overlayRef.attachments().subscribe(null, null, attachCompleteSpy); + overlayRef.detachments().subscribe(disposeSpy, null, detachCompleteSpy); + + overlayRef.attach(componentPortal); + overlayRef.dispose(); + + expect(disposeSpy).toHaveBeenCalled(); + expect(attachCompleteSpy).toHaveBeenCalled(); + expect(detachCompleteSpy).toHaveBeenCalled(); + }); + describe('positioning', () => { let state: OverlayState; @@ -295,6 +335,48 @@ describe('Overlay', () => { }); }); + + describe('scroll strategy', () => { + let fakeScrollStrategy: FakeScrollStrategy; + let config: OverlayState; + + beforeEach(() => { + config = new OverlayState(); + fakeScrollStrategy = new FakeScrollStrategy(); + config.scrollStrategy = fakeScrollStrategy; + }); + + it('should attach the overlay ref to the scroll strategy', () => { + let overlayRef = overlay.create(config); + + expect(fakeScrollStrategy.overlayRef).toBe(overlayRef, + 'Expected scroll strategy to have been attached to the current overlay ref.'); + }); + + it('should enable the scroll strategy when the overlay is attached', () => { + let overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(fakeScrollStrategy.isEnabled).toBe(true, 'Expected scroll strategy to be enabled.'); + }); + + it('should disable the scroll strategy once the overlay is detached', () => { + let overlayRef = overlay.create(config); + + overlayRef.attach(componentPortal); + expect(fakeScrollStrategy.isEnabled).toBe(true, 'Expected scroll strategy to be enabled.'); + + overlayRef.detach(); + expect(fakeScrollStrategy.isEnabled).toBe(false, 'Expected scroll strategy to be disabled.'); + }); + + it('should disable the scroll strategy when the overlay is destroyed', () => { + let overlayRef = overlay.create(config); + + overlayRef.dispose(); + expect(fakeScrollStrategy.isEnabled).toBe(false, 'Expected scroll strategy to be disabled.'); + }); + }); }); describe('OverlayContainer theming', () => { @@ -365,3 +447,19 @@ class FakePositionStrategy implements PositionStrategy { dispose() {} } +class FakeScrollStrategy implements ScrollStrategy { + isEnabled = false; + overlayRef: OverlayRef; + + attach(overlayRef: OverlayRef) { + this.overlayRef = overlayRef; + } + + enable() { + this.isEnabled = true; + } + + disable() { + this.isEnabled = false; + } +} diff --git a/src/lib/core/overlay/overlay.ts b/src/lib/core/overlay/overlay.ts index 1105a869c52e..827e2879836d 100644 --- a/src/lib/core/overlay/overlay.ts +++ b/src/lib/core/overlay/overlay.ts @@ -30,7 +30,7 @@ let defaultState = new OverlayState(); * * An overlay *is* a PortalHost, so any kind of Portal can be loaded into one. */ - @Injectable() +@Injectable() export class Overlay { constructor(private _overlayContainer: OverlayContainer, private _componentFactoryResolver: ComponentFactoryResolver, diff --git a/src/lib/core/overlay/scroll/close-scroll-strategy.spec.ts b/src/lib/core/overlay/scroll/close-scroll-strategy.spec.ts new file mode 100644 index 000000000000..d92e62e1d8de --- /dev/null +++ b/src/lib/core/overlay/scroll/close-scroll-strategy.spec.ts @@ -0,0 +1,78 @@ +import {inject, TestBed, async} from '@angular/core/testing'; +import {NgModule, Component} from '@angular/core'; +import {Subject} from 'rxjs/Subject'; +import { + PortalModule, + ComponentPortal, + Overlay, + OverlayState, + OverlayRef, + OverlayModule, + ScrollStrategy, + ScrollDispatcher, + CloseScrollStrategy, +} from '../../core'; + + +describe('CloseScrollStrategy', () => { + let overlayRef: OverlayRef; + let componentPortal: ComponentPortal; + let scrolledSubject = new Subject(); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [OverlayModule, PortalModule, OverlayTestModule], + providers: [ + {provide: ScrollDispatcher, useFactory: () => { + return {scrolled: (delay: number, callback: () => any) => { + return scrolledSubject.asObservable().subscribe(callback); + }}; + }} + ] + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([Overlay, ScrollDispatcher], (overlay: Overlay, + scrollDispatcher: ScrollDispatcher) => { + + let overlayState = new OverlayState(); + overlayState.scrollStrategy = new CloseScrollStrategy(scrollDispatcher); + overlayRef = overlay.create(overlayState); + componentPortal = new ComponentPortal(MozarellaMsg); + })); + + it('should detach the overlay as soon as the user scrolls', () => { + overlayRef.attach(componentPortal); + spyOn(overlayRef, 'detach'); + + scrolledSubject.next(); + expect(overlayRef.detach).toHaveBeenCalled(); + }); + + it('should not attempt to detach the overlay after it has been detached', () => { + overlayRef.attach(componentPortal); + overlayRef.detach(); + + spyOn(overlayRef, 'detach'); + scrolledSubject.next(); + + expect(overlayRef.detach).not.toHaveBeenCalled(); + }); + +}); + + +/** Simple component that we can attach to the overlay. */ +@Component({template: '

Mozarella

'}) +class MozarellaMsg { } + + +/** Test module to hold the component. */ +@NgModule({ + imports: [OverlayModule, PortalModule], + declarations: [MozarellaMsg], + entryComponents: [MozarellaMsg], +}) +class OverlayTestModule { } diff --git a/src/lib/core/overlay/scroll/close-scroll-strategy.ts b/src/lib/core/overlay/scroll/close-scroll-strategy.ts new file mode 100644 index 000000000000..28f6a3d012c4 --- /dev/null +++ b/src/lib/core/overlay/scroll/close-scroll-strategy.ts @@ -0,0 +1,38 @@ +import {ScrollStrategy} from './scroll-strategy'; +import {OverlayRef} from '../overlay-ref'; +import {Subscription} from 'rxjs/Subscription'; +import {ScrollDispatcher} from './scroll-dispatcher'; + + +/** + * Strategy that will close the overlay as soon as the user starts scrolling. + */ +export class CloseScrollStrategy implements ScrollStrategy { + private _scrollSubscription: Subscription|null = null; + private _overlayRef: OverlayRef; + + constructor(private _scrollDispatcher: ScrollDispatcher) { } + + attach(overlayRef: OverlayRef) { + this._overlayRef = overlayRef; + } + + enable() { + if (!this._scrollSubscription) { + this._scrollSubscription = this._scrollDispatcher.scrolled(null, () => { + if (this._overlayRef.hasAttached()) { + this._overlayRef.detach(); + } + + this.disable(); + }); + } + } + + disable() { + if (this._scrollSubscription) { + this._scrollSubscription.unsubscribe(); + this._scrollSubscription = null; + } + } +} diff --git a/src/lib/core/overlay/scroll/noop-scroll-strategy.ts b/src/lib/core/overlay/scroll/noop-scroll-strategy.ts new file mode 100644 index 000000000000..3d50a8f7743a --- /dev/null +++ b/src/lib/core/overlay/scroll/noop-scroll-strategy.ts @@ -0,0 +1,10 @@ +import {ScrollStrategy} from './scroll-strategy'; + +/** + * Scroll strategy that doesn't do anything. + */ +export class NoopScrollStrategy implements ScrollStrategy { + enable() { } + disable() { } + attach() { } +} diff --git a/src/lib/core/overlay/scroll/reposition-scroll-strategy.spec.ts b/src/lib/core/overlay/scroll/reposition-scroll-strategy.spec.ts new file mode 100644 index 000000000000..20e396d32d04 --- /dev/null +++ b/src/lib/core/overlay/scroll/reposition-scroll-strategy.spec.ts @@ -0,0 +1,91 @@ +import {inject, TestBed, async} from '@angular/core/testing'; +import {NgModule, Component} from '@angular/core'; +import {Subject} from 'rxjs/Subject'; +import { + PortalModule, + ComponentPortal, + Overlay, + OverlayState, + OverlayRef, + OverlayModule, + ScrollStrategy, + ScrollDispatcher, + RepositionScrollStrategy, +} from '../../core'; + + +describe('RepositionScrollStrategy', () => { + let overlayRef: OverlayRef; + let componentPortal: ComponentPortal; + let scrolledSubject = new Subject(); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [OverlayModule, PortalModule, OverlayTestModule], + providers: [ + {provide: ScrollDispatcher, useFactory: () => { + return {scrolled: (delay: number, callback: () => any) => { + return scrolledSubject.asObservable().subscribe(callback); + }}; + }} + ] + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([Overlay, ScrollDispatcher], (overlay: Overlay, + scrollDispatcher: ScrollDispatcher) => { + + let overlayState = new OverlayState(); + overlayState.scrollStrategy = new RepositionScrollStrategy(scrollDispatcher); + overlayRef = overlay.create(overlayState); + componentPortal = new ComponentPortal(PastaMsg); + })); + + it('should update the overlay position when the page is scrolled', () => { + overlayRef.attach(componentPortal); + spyOn(overlayRef, 'updatePosition'); + + scrolledSubject.next(); + expect(overlayRef.updatePosition).toHaveBeenCalledTimes(1); + + scrolledSubject.next(); + expect(overlayRef.updatePosition).toHaveBeenCalledTimes(2); + }); + + it('should not be updating the position after the overlay is detached', () => { + overlayRef.attach(componentPortal); + spyOn(overlayRef, 'updatePosition'); + + overlayRef.detach(); + scrolledSubject.next(); + + expect(overlayRef.updatePosition).not.toHaveBeenCalled(); + }); + + it('should not be updating the position after the overlay is destroyed', () => { + overlayRef.attach(componentPortal); + spyOn(overlayRef, 'updatePosition'); + + overlayRef.dispose(); + scrolledSubject.next(); + + expect(overlayRef.updatePosition).not.toHaveBeenCalled(); + }); + +}); + + +/** Simple component that we can attach to the overlay. */ +@Component({template: '

Pasta

'}) +class PastaMsg { } + + +/** Test module to hold the component. */ +@NgModule({ + imports: [OverlayModule, PortalModule], + declarations: [PastaMsg], + entryComponents: [PastaMsg], +}) +class OverlayTestModule { } diff --git a/src/lib/core/overlay/scroll/reposition-scroll-strategy.ts b/src/lib/core/overlay/scroll/reposition-scroll-strategy.ts new file mode 100644 index 000000000000..924584ceb3a3 --- /dev/null +++ b/src/lib/core/overlay/scroll/reposition-scroll-strategy.ts @@ -0,0 +1,34 @@ +import {Subscription} from 'rxjs/Subscription'; +import {ScrollStrategy} from './scroll-strategy'; +import {OverlayRef} from '../overlay-ref'; +import {ScrollDispatcher} from './scroll-dispatcher'; + + +/** + * Strategy that will update the element position as the user is scrolling. + */ +export class RepositionScrollStrategy implements ScrollStrategy { + private _scrollSubscription: Subscription|null = null; + private _overlayRef: OverlayRef; + + constructor(private _scrollDispatcher: ScrollDispatcher, private _scrollThrottle = 0) { } + + attach(overlayRef: OverlayRef) { + this._overlayRef = overlayRef; + } + + enable() { + if (!this._scrollSubscription) { + this._scrollSubscription = this._scrollDispatcher.scrolled(this._scrollThrottle, () => { + this._overlayRef.updatePosition(); + }); + } + } + + disable() { + if (this._scrollSubscription) { + this._scrollSubscription.unsubscribe(); + this._scrollSubscription = null; + } + } +} diff --git a/src/lib/core/overlay/scroll/scroll-strategy.ts b/src/lib/core/overlay/scroll/scroll-strategy.ts new file mode 100644 index 000000000000..37fcada36e6d --- /dev/null +++ b/src/lib/core/overlay/scroll/scroll-strategy.ts @@ -0,0 +1,11 @@ +import {OverlayRef} from '../overlay-ref'; + +/** + * Describes a strategy that will be used by an overlay + * to handle scroll events while it is open. + */ +export interface ScrollStrategy { + enable: () => void; + disable: () => void; + attach: (overlayRef: OverlayRef) => void; +} diff --git a/src/lib/menu/menu-trigger.ts b/src/lib/menu/menu-trigger.ts index ea05dee1b3f9..3f9c35a35a1c 100644 --- a/src/lib/menu/menu-trigger.ts +++ b/src/lib/menu/menu-trigger.ts @@ -22,6 +22,8 @@ import { ConnectedPositionStrategy, HorizontalConnectionPos, VerticalConnectionPos, + RepositionScrollStrategy, + ScrollDispatcher, } from '../core'; import {Subscription} from 'rxjs/Subscription'; import {MenuPositionX, MenuPositionY} from './menu-positions'; @@ -78,7 +80,8 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy { @Output() onMenuClose = new EventEmitter(); constructor(private _overlay: Overlay, private _element: ElementRef, - private _viewContainerRef: ViewContainerRef, @Optional() private _dir: Dir) { } + private _viewContainerRef: ViewContainerRef, @Optional() private _dir: Dir, + private _scrollDispatcher: ScrollDispatcher) { } ngAfterViewInit() { this._checkMenu(); @@ -216,6 +219,7 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy { overlayState.hasBackdrop = true; overlayState.backdropClass = 'cdk-overlay-transparent-backdrop'; overlayState.direction = this.dir; + overlayState.scrollStrategy = new RepositionScrollStrategy(this._scrollDispatcher); return overlayState; } diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 8738c844da2e..737ba26901d8 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -29,7 +29,6 @@ import {coerceBooleanProperty} from '../core/coercion/boolean-property'; import {ConnectedOverlayDirective} from '../core/overlay/overlay-directives'; import {ViewportRuler} from '../core/overlay/position/viewport-ruler'; import {SelectionModel} from '../core/selection/selection'; -import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher'; import {MdSelectDynamicMultipleError, MdSelectNonArrayValueError} from './select-errors'; import 'rxjs/add/observable/merge'; import 'rxjs/add/operator/startWith'; @@ -134,9 +133,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal /** Subscription to tab events while overlay is focused. */ private _tabSubscription: Subscription; - /** Subscription to global scrolled events while the select is open. */ - private _scrollSubscription: Subscription; - /** Whether filling out the select is required in the form. */ private _required: boolean = false; @@ -314,8 +310,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal constructor(private _element: ElementRef, private _renderer: Renderer2, private _viewportRuler: ViewportRuler, private _changeDetectorRef: ChangeDetectorRef, - private _scrollDispatcher: ScrollDispatcher, @Optional() private _dir: Dir, - @Self() @Optional() public _control: NgControl, + @Optional() private _dir: Dir, @Self() @Optional() public _control: NgControl, @Attribute('tabindex') tabIndex: string) { if (this._control) { @@ -374,9 +369,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal this._calculateOverlayPosition(); this._placeholderState = this._floatPlaceholderState(); this._panelOpen = true; - this._scrollSubscription = this._scrollDispatcher.scrolled(0, () => { - this.overlayDir.overlayRef.updatePosition(); - }); } /** Closes the overlay panel and focuses the host element. */ @@ -388,11 +380,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal this._placeholderState = ''; } - if (this._scrollSubscription) { - this._scrollSubscription.unsubscribe(); - this._scrollSubscription = null; - } - this._focusHost(); } } diff --git a/src/lib/tooltip/tooltip.spec.ts b/src/lib/tooltip/tooltip.spec.ts index e36226016031..5da630f96540 100644 --- a/src/lib/tooltip/tooltip.spec.ts +++ b/src/lib/tooltip/tooltip.spec.ts @@ -444,14 +444,6 @@ describe('MdTooltip', () => { })); }); - describe('destroy', () => { - it('does not throw an error on destroy', () => { - const fixture = TestBed.createComponent(BasicTooltipDemo); - fixture.detectChanges(); - delete fixture.componentInstance.tooltip.scrollSubscription; - expect(fixture.destroy.bind(fixture)).not.toThrow(); - }); - }); }); @Component({ diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts index 594e05cf5d8f..acdbd22861ce 100644 --- a/src/lib/tooltip/tooltip.ts +++ b/src/lib/tooltip/tooltip.ts @@ -8,7 +8,6 @@ import { Optional, OnDestroy, Renderer2, - OnInit, ChangeDetectorRef, } from '@angular/core'; import { @@ -26,6 +25,7 @@ import { ComponentPortal, OverlayConnectionPosition, OriginConnectionPosition, + RepositionScrollStrategy, } from '../core'; import {MdTooltipInvalidPositionError} from './tooltip-errors'; import {Observable} from 'rxjs/Observable'; @@ -34,13 +34,12 @@ import {Dir} from '../core/rtl/dir'; import {Platform} from '../core/platform/index'; import 'rxjs/add/operator/first'; import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher'; -import {Subscription} from 'rxjs/Subscription'; import {coerceBooleanProperty} from '../core/coercion/boolean-property'; export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after'; /** Time in ms to delay before changing the tooltip visibility to hidden */ -export const TOUCHEND_HIDE_DELAY = 1500; +export const TOUCHEND_HIDE_DELAY = 1500; /** Time in ms to throttle repositioning after scroll events. */ export const SCROLL_THROTTLE_MS = 20; @@ -59,10 +58,9 @@ export const SCROLL_THROTTLE_MS = 20; }, exportAs: 'mdTooltip', }) -export class MdTooltip implements OnInit, OnDestroy { +export class MdTooltip implements OnDestroy { _overlayRef: OverlayRef; _tooltipInstance: TooltipComponent; - scrollSubscription: Subscription; private _position: TooltipPosition = 'below'; private _disabled: boolean = false; @@ -164,16 +162,6 @@ export class MdTooltip implements OnInit, OnDestroy { } } - ngOnInit() { - // When a scroll on the page occurs, update the position in case this tooltip needs - // to be repositioned. - this.scrollSubscription = this._scrollDispatcher.scrolled(SCROLL_THROTTLE_MS, () => { - if (this._overlayRef) { - this._overlayRef.updatePosition(); - } - }); - } - /** * Dispose the tooltip when destroyed. */ @@ -181,10 +169,6 @@ export class MdTooltip implements OnInit, OnDestroy { if (this._tooltipInstance) { this._disposeTooltip(); } - - if (this.scrollSubscription) { - this.scrollSubscription.unsubscribe(); - } } /** Shows the tooltip after the delay in ms, defaults to tooltip-delay-show or 0ms if no input */ @@ -249,6 +233,8 @@ export class MdTooltip implements OnInit, OnDestroy { }); let config = new OverlayState(); config.positionStrategy = strategy; + config.scrollStrategy = + new RepositionScrollStrategy(this._scrollDispatcher, SCROLL_THROTTLE_MS); this._overlayRef = this._overlay.create(config); }