Skip to content

Commit

Permalink
feat(overlay): add scroll handling strategies (#4293)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
crisbeto authored and andrewseguin committed May 2, 2017
1 parent afaa2dc commit c8ec981
Show file tree
Hide file tree
Showing 17 changed files with 416 additions and 59 deletions.
17 changes: 2 additions & 15 deletions src/lib/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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();

Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions src/lib/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions src/lib/core/overlay/overlay-directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -156,6 +162,7 @@ export class ConnectedOverlayDirective implements OnDestroy {
constructor(
private _overlay: Overlay,
private _renderer: Renderer2,
private _scrollDispatcher: ScrollDispatcher,
templateRef: TemplateRef<any>,
viewContainerRef: ViewContainerRef,
@Optional() private _dir: Dir) {
Expand Down Expand Up @@ -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;
}
Expand Down
26 changes: 25 additions & 1 deletion src/lib/core/overlay/overlay-ref.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,12 +13,17 @@ import {Subject} from 'rxjs/Subject';
export class OverlayRef implements PortalHost {
private _backdropElement: HTMLElement = null;
private _backdropClick: Subject<any> = new Subject();
private _attachments = new Subject<void>();
private _detachments = new Subject<void>();

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 {
Expand All @@ -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);
Expand All @@ -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();
}
Expand All @@ -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();
}

/**
Expand All @@ -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<void> {
return this._attachments.asObservable();
}

/** Returns an observable that emits when the overlay has been detached. */
detachments(): Observable<void> {
return this._detachments.asObservable();
}

/**
* Gets the current state config of the overlay.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/lib/core/overlay/overlay-state.ts
Original file line number Diff line number Diff line change
@@ -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';


/**
Expand All @@ -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;

Expand Down
98 changes: 98 additions & 0 deletions src/lib/core/overlay/overlay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion src/lib/core/overlay/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit c8ec981

Please sign in to comment.