Skip to content

Commit c8ec981

Browse files
crisbetoandrewseguin
authored andcommitted
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
1 parent afaa2dc commit c8ec981

17 files changed

+416
-59
lines changed

src/lib/autocomplete/autocomplete-trigger.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '@angular/core';
1414
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
1515
import {DOCUMENT} from '@angular/platform-browser';
16-
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
16+
import {Overlay, OverlayRef, OverlayState, TemplatePortal, RepositionScrollStrategy} from '../core';
1717
import {MdAutocomplete} from './autocomplete';
1818
import {PositionStrategy} from '../core/overlay/position/position-strategy';
1919
import {ConnectedPositionStrategy} from '../core/overlay/position/connected-position-strategy';
@@ -76,9 +76,6 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
7676
/** The subscription to positioning changes in the autocomplete panel. */
7777
private _panelPositionSubscription: Subscription;
7878

79-
/** Subscription to global scroll events. */
80-
private _scrollSubscription: Subscription;
81-
8279
/** Strategy that is used to position the panel. */
8380
private _positionStrategy: ConnectedPositionStrategy;
8481

@@ -139,12 +136,6 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
139136
this._subscribeToClosingActions();
140137
}
141138

142-
if (!this._scrollSubscription) {
143-
this._scrollSubscription = this._scrollDispatcher.scrolled(0, () => {
144-
this._overlayRef.updatePosition();
145-
});
146-
}
147-
148139
this.autocomplete._setVisibility();
149140
this._floatPlaceholder();
150141
this._panelOpen = true;
@@ -156,11 +147,6 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
156147
this._overlayRef.detach();
157148
}
158149

159-
if (this._scrollSubscription) {
160-
this._scrollSubscription.unsubscribe();
161-
this._scrollSubscription = null;
162-
}
163-
164150
this._panelOpen = false;
165151
this._resetPlaceholder();
166152

@@ -374,6 +360,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
374360
overlayState.positionStrategy = this._getOverlayPosition();
375361
overlayState.width = this._getHostWidth();
376362
overlayState.direction = this._dir ? this._dir.value : 'ltr';
363+
overlayState.scrollStrategy = new RepositionScrollStrategy(this._scrollDispatcher);
377364
return overlayState;
378365
}
379366

src/lib/core/core.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export * from './overlay/position/global-position-strategy';
5151
export * from './overlay/position/connected-position-strategy';
5252
export * from './overlay/position/connected-position';
5353
export {ScrollDispatcher} from './overlay/scroll/scroll-dispatcher';
54+
export {ScrollStrategy} from './overlay/scroll/scroll-strategy';
55+
export {RepositionScrollStrategy} from './overlay/scroll/reposition-scroll-strategy';
56+
export {CloseScrollStrategy} from './overlay/scroll/close-scroll-strategy';
57+
export {NoopScrollStrategy} from './overlay/scroll/noop-scroll-strategy';
5458

5559
// Gestures
5660
export {GestureConfig} from './gestures/gesture-config';

src/lib/core/overlay/overlay-directives.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ import {PortalModule} from '../portal/portal-directives';
2323
import {ConnectedPositionStrategy} from './position/connected-position-strategy';
2424
import {Dir, LayoutDirection} from '../rtl/dir';
2525
import {Scrollable} from './scroll/scrollable';
26+
import {RepositionScrollStrategy} from './scroll/reposition-scroll-strategy';
27+
import {ScrollStrategy} from './scroll/scroll-strategy';
2628
import {coerceBooleanProperty} from '../coercion/boolean-property';
2729
import {ESCAPE} from '../keyboard/keycodes';
30+
import {ScrollDispatcher} from './scroll/scroll-dispatcher';
2831
import {Subscription} from 'rxjs/Subscription';
2932

3033

@@ -119,6 +122,9 @@ export class ConnectedOverlayDirective implements OnDestroy {
119122
/** The custom class to be set on the backdrop element. */
120123
@Input() backdropClass: string;
121124

125+
/** Strategy to be used when handling scroll events while the overlay is open. */
126+
@Input() scrollStrategy: ScrollStrategy = new RepositionScrollStrategy(this._scrollDispatcher);
127+
122128
/** Whether or not the overlay should attach a backdrop. */
123129
@Input()
124130
get hasBackdrop() {
@@ -156,6 +162,7 @@ export class ConnectedOverlayDirective implements OnDestroy {
156162
constructor(
157163
private _overlay: Overlay,
158164
private _renderer: Renderer2,
165+
private _scrollDispatcher: ScrollDispatcher,
159166
templateRef: TemplateRef<any>,
160167
viewContainerRef: ViewContainerRef,
161168
@Optional() private _dir: Dir) {
@@ -213,6 +220,7 @@ export class ConnectedOverlayDirective implements OnDestroy {
213220

214221
this._position = this._createPositionStrategy() as ConnectedPositionStrategy;
215222
overlayConfig.positionStrategy = this._position;
223+
overlayConfig.scrollStrategy = this.scrollStrategy;
216224

217225
return overlayConfig;
218226
}

src/lib/core/overlay/overlay-ref.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {NgZone} from '@angular/core';
22
import {PortalHost, Portal} from '../portal/portal';
33
import {OverlayState} from './overlay-state';
4+
import {ScrollStrategy} from './scroll/scroll-strategy';
45
import {Observable} from 'rxjs/Observable';
56
import {Subject} from 'rxjs/Subject';
67

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

1619
constructor(
1720
private _portalHost: PortalHost,
1821
private _pane: HTMLElement,
1922
private _state: OverlayState,
20-
private _ngZone: NgZone) { }
23+
private _ngZone: NgZone) {
24+
25+
this._state.scrollStrategy.attach(this);
26+
}
2127

2228
/** The overlay's HTML element */
2329
get overlayElement(): HTMLElement {
@@ -37,6 +43,8 @@ export class OverlayRef implements PortalHost {
3743
this.updateSize();
3844
this.updateDirection();
3945
this.updatePosition();
46+
this._attachments.next();
47+
this._state.scrollStrategy.enable();
4048

4149
// Enable pointer events for the overlay pane element.
4250
this._togglePointerEvents(true);
@@ -59,6 +67,8 @@ export class OverlayRef implements PortalHost {
5967
// This is necessary because otherwise the pane element will cover the page and disable
6068
// pointer events therefore. Depends on the position strategy and the applied pane boundaries.
6169
this._togglePointerEvents(false);
70+
this._state.scrollStrategy.disable();
71+
this._detachments.next();
6272

6373
return this._portalHost.detach();
6474
}
@@ -73,6 +83,10 @@ export class OverlayRef implements PortalHost {
7383

7484
this.detachBackdrop();
7585
this._portalHost.dispose();
86+
this._state.scrollStrategy.disable();
87+
this._detachments.next();
88+
this._detachments.complete();
89+
this._attachments.complete();
7690
}
7791

7892
/**
@@ -89,6 +103,16 @@ export class OverlayRef implements PortalHost {
89103
return this._backdropClick.asObservable();
90104
}
91105

106+
/** Returns an observable that emits when the overlay has been attached. */
107+
attachments(): Observable<void> {
108+
return this._attachments.asObservable();
109+
}
110+
111+
/** Returns an observable that emits when the overlay has been detached. */
112+
detachments(): Observable<void> {
113+
return this._detachments.asObservable();
114+
}
115+
92116
/**
93117
* Gets the current state config of the overlay.
94118
*/

src/lib/core/overlay/overlay-state.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {PositionStrategy} from './position/position-strategy';
22
import {LayoutDirection} from '../rtl/dir';
3+
import {ScrollStrategy} from './scroll/scroll-strategy';
4+
import {NoopScrollStrategy} from './scroll/noop-scroll-strategy';
35

46

57
/**
@@ -10,6 +12,9 @@ export class OverlayState {
1012
/** Strategy with which to position the overlay. */
1113
positionStrategy: PositionStrategy;
1214

15+
/** Strategy to be used when handling scroll events while the overlay is open. */
16+
scrollStrategy: ScrollStrategy = new NoopScrollStrategy();
17+
1318
/** Whether the overlay has a backdrop. */
1419
hasBackdrop: boolean = false;
1520

src/lib/core/overlay/overlay.spec.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import {TemplatePortal, ComponentPortal} from '../portal/portal';
55
import {Overlay} from './overlay';
66
import {OverlayContainer} from './overlay-container';
77
import {OverlayState} from './overlay-state';
8+
import {OverlayRef} from './overlay-ref';
89
import {PositionStrategy} from './position/position-strategy';
910
import {OverlayModule} from './overlay-directives';
11+
import {ScrollStrategy} from './scroll/scroll-strategy';
1012

1113

1214
describe('Overlay', () => {
@@ -135,6 +137,44 @@ describe('Overlay', () => {
135137
expect(pane.getAttribute('dir')).toEqual('rtl');
136138
});
137139

140+
it('should emit when an overlay is attached', () => {
141+
let overlayRef = overlay.create();
142+
let spy = jasmine.createSpy('attachments spy');
143+
144+
overlayRef.attachments().subscribe(spy);
145+
overlayRef.attach(componentPortal);
146+
147+
expect(spy).toHaveBeenCalled();
148+
});
149+
150+
it('should emit when an overlay is detached', () => {
151+
let overlayRef = overlay.create();
152+
let spy = jasmine.createSpy('detachments spy');
153+
154+
overlayRef.detachments().subscribe(spy);
155+
overlayRef.attach(componentPortal);
156+
overlayRef.detach();
157+
158+
expect(spy).toHaveBeenCalled();
159+
});
160+
161+
it('should emit and complete the observables when an overlay is disposed', () => {
162+
let overlayRef = overlay.create();
163+
let disposeSpy = jasmine.createSpy('dispose spy');
164+
let attachCompleteSpy = jasmine.createSpy('attachCompleteSpy spy');
165+
let detachCompleteSpy = jasmine.createSpy('detachCompleteSpy spy');
166+
167+
overlayRef.attachments().subscribe(null, null, attachCompleteSpy);
168+
overlayRef.detachments().subscribe(disposeSpy, null, detachCompleteSpy);
169+
170+
overlayRef.attach(componentPortal);
171+
overlayRef.dispose();
172+
173+
expect(disposeSpy).toHaveBeenCalled();
174+
expect(attachCompleteSpy).toHaveBeenCalled();
175+
expect(detachCompleteSpy).toHaveBeenCalled();
176+
});
177+
138178
describe('positioning', () => {
139179
let state: OverlayState;
140180

@@ -295,6 +335,48 @@ describe('Overlay', () => {
295335
});
296336

297337
});
338+
339+
describe('scroll strategy', () => {
340+
let fakeScrollStrategy: FakeScrollStrategy;
341+
let config: OverlayState;
342+
343+
beforeEach(() => {
344+
config = new OverlayState();
345+
fakeScrollStrategy = new FakeScrollStrategy();
346+
config.scrollStrategy = fakeScrollStrategy;
347+
});
348+
349+
it('should attach the overlay ref to the scroll strategy', () => {
350+
let overlayRef = overlay.create(config);
351+
352+
expect(fakeScrollStrategy.overlayRef).toBe(overlayRef,
353+
'Expected scroll strategy to have been attached to the current overlay ref.');
354+
});
355+
356+
it('should enable the scroll strategy when the overlay is attached', () => {
357+
let overlayRef = overlay.create(config);
358+
359+
overlayRef.attach(componentPortal);
360+
expect(fakeScrollStrategy.isEnabled).toBe(true, 'Expected scroll strategy to be enabled.');
361+
});
362+
363+
it('should disable the scroll strategy once the overlay is detached', () => {
364+
let overlayRef = overlay.create(config);
365+
366+
overlayRef.attach(componentPortal);
367+
expect(fakeScrollStrategy.isEnabled).toBe(true, 'Expected scroll strategy to be enabled.');
368+
369+
overlayRef.detach();
370+
expect(fakeScrollStrategy.isEnabled).toBe(false, 'Expected scroll strategy to be disabled.');
371+
});
372+
373+
it('should disable the scroll strategy when the overlay is destroyed', () => {
374+
let overlayRef = overlay.create(config);
375+
376+
overlayRef.dispose();
377+
expect(fakeScrollStrategy.isEnabled).toBe(false, 'Expected scroll strategy to be disabled.');
378+
});
379+
});
298380
});
299381

300382
describe('OverlayContainer theming', () => {
@@ -365,3 +447,19 @@ class FakePositionStrategy implements PositionStrategy {
365447
dispose() {}
366448
}
367449

450+
class FakeScrollStrategy implements ScrollStrategy {
451+
isEnabled = false;
452+
overlayRef: OverlayRef;
453+
454+
attach(overlayRef: OverlayRef) {
455+
this.overlayRef = overlayRef;
456+
}
457+
458+
enable() {
459+
this.isEnabled = true;
460+
}
461+
462+
disable() {
463+
this.isEnabled = false;
464+
}
465+
}

src/lib/core/overlay/overlay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ let defaultState = new OverlayState();
3030
*
3131
* An overlay *is* a PortalHost, so any kind of Portal can be loaded into one.
3232
*/
33-
@Injectable()
33+
@Injectable()
3434
export class Overlay {
3535
constructor(private _overlayContainer: OverlayContainer,
3636
private _componentFactoryResolver: ComponentFactoryResolver,

0 commit comments

Comments
 (0)