Skip to content

Commit

Permalink
feat(overlay): add scroll handling strategies
Browse files Browse the repository at this point in the history
* 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 angular#4093.
  • Loading branch information
crisbeto committed Apr 29, 2017
1 parent b4e8c7d commit 7ea9f7e
Show file tree
Hide file tree
Showing 17 changed files with 346 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
9 changes: 8 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 @@ -17,7 +18,10 @@ export class OverlayRef implements PortalHost {
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 +41,7 @@ export class OverlayRef implements PortalHost {
this.updateSize();
this.updateDirection();
this.updatePosition();
this._state.scrollStrategy.enable();

// Enable pointer events for the overlay pane element.
this._togglePointerEvents(true);
Expand All @@ -59,6 +64,7 @@ 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();

return this._portalHost.detach();
}
Expand All @@ -73,6 +79,7 @@ export class OverlayRef implements PortalHost {

this.detachBackdrop();
this._portalHost.dispose();
this._state.scrollStrategy.disable();
}

/**
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
45 changes: 45 additions & 0 deletions src/lib/core/overlay/overlay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {OverlayContainer} from './overlay-container';
import {OverlayState} from './overlay-state';
import {PositionStrategy} from './position/position-strategy';
import {OverlayModule} from './overlay-directives';
import {ScrollStrategy} from './scroll/scroll-strategy';


describe('Overlay', () => {
Expand Down Expand Up @@ -295,6 +296,50 @@ describe('Overlay', () => {
});

});

describe('scroll strategy', () => {
let fakeScrollStrategy: ScrollStrategy;
let config: OverlayState;

beforeEach(() => {
config = new OverlayState();
fakeScrollStrategy = {
attach: jasmine.createSpy('attach spy'),
enable: jasmine.createSpy('enable spy'),
disable: jasmine.createSpy('disable spy')
};
config.scrollStrategy = fakeScrollStrategy;
});

it('should attach the overlay ref to the scroll strategy', () => {
let overlayRef = overlay.create(config);

expect(fakeScrollStrategy.attach).toHaveBeenCalledWith(overlayRef);
});

it('should enable the scroll strategy when the overlay is attached', () => {
let overlayRef = overlay.create(config);

overlayRef.attach(componentPortal);
expect(fakeScrollStrategy.enable).toHaveBeenCalled();
});

it('should disable the scroll strategy once the overlay is detached', () => {
let overlayRef = overlay.create(config);

overlayRef.attach(componentPortal);
overlayRef.detach();

expect(fakeScrollStrategy.disable).toHaveBeenCalled();
});

it('should disable the scroll strategy when the overlay is destroyed', () => {
let overlayRef = overlay.create(config);

overlayRef.dispose();
expect(fakeScrollStrategy.disable).toHaveBeenCalled();
});
});
});

describe('OverlayContainer theming', () => {
Expand Down
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
78 changes: 78 additions & 0 deletions src/lib/core/overlay/scroll/close-scroll-strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -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<MozarellaMsg>;
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: '<p>Mozarella</p>'})
class MozarellaMsg { }


/** Test module to hold the component. */
@NgModule({
imports: [OverlayModule, PortalModule],
declarations: [MozarellaMsg],
entryComponents: [MozarellaMsg],
})
class OverlayTestModule { }
38 changes: 38 additions & 0 deletions src/lib/core/overlay/scroll/close-scroll-strategy.ts
Original file line number Diff line number Diff line change
@@ -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) { }

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;
}
}
}
10 changes: 10 additions & 0 deletions src/lib/core/overlay/scroll/noop-scroll-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {ScrollStrategy} from './scroll-strategy';

/**
* Scroll strategy that doesn't do anything.
*/
export class NoopScrollStrategy implements ScrollStrategy {
enable() { }
disable() { }
attach() { }
}

0 comments on commit 7ea9f7e

Please sign in to comment.