Skip to content

Commit 7ea9f7e

Browse files
committed
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.
1 parent b4e8c7d commit 7ea9f7e

17 files changed

+346
-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: 8 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

@@ -17,7 +18,10 @@ export class OverlayRef implements PortalHost {
1718
private _portalHost: PortalHost,
1819
private _pane: HTMLElement,
1920
private _state: OverlayState,
20-
private _ngZone: NgZone) { }
21+
private _ngZone: NgZone) {
22+
23+
this._state.scrollStrategy.attach(this);
24+
}
2125

2226
/** The overlay's HTML element */
2327
get overlayElement(): HTMLElement {
@@ -37,6 +41,7 @@ export class OverlayRef implements PortalHost {
3741
this.updateSize();
3842
this.updateDirection();
3943
this.updatePosition();
44+
this._state.scrollStrategy.enable();
4045

4146
// Enable pointer events for the overlay pane element.
4247
this._togglePointerEvents(true);
@@ -59,6 +64,7 @@ export class OverlayRef implements PortalHost {
5964
// This is necessary because otherwise the pane element will cover the page and disable
6065
// pointer events therefore. Depends on the position strategy and the applied pane boundaries.
6166
this._togglePointerEvents(false);
67+
this._state.scrollStrategy.disable();
6268

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

7480
this.detachBackdrop();
7581
this._portalHost.dispose();
82+
this._state.scrollStrategy.disable();
7683
}
7784

7885
/**

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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {OverlayContainer} from './overlay-container';
77
import {OverlayState} from './overlay-state';
88
import {PositionStrategy} from './position/position-strategy';
99
import {OverlayModule} from './overlay-directives';
10+
import {ScrollStrategy} from './scroll/scroll-strategy';
1011

1112

1213
describe('Overlay', () => {
@@ -295,6 +296,50 @@ describe('Overlay', () => {
295296
});
296297

297298
});
299+
300+
describe('scroll strategy', () => {
301+
let fakeScrollStrategy: ScrollStrategy;
302+
let config: OverlayState;
303+
304+
beforeEach(() => {
305+
config = new OverlayState();
306+
fakeScrollStrategy = {
307+
attach: jasmine.createSpy('attach spy'),
308+
enable: jasmine.createSpy('enable spy'),
309+
disable: jasmine.createSpy('disable spy')
310+
};
311+
config.scrollStrategy = fakeScrollStrategy;
312+
});
313+
314+
it('should attach the overlay ref to the scroll strategy', () => {
315+
let overlayRef = overlay.create(config);
316+
317+
expect(fakeScrollStrategy.attach).toHaveBeenCalledWith(overlayRef);
318+
});
319+
320+
it('should enable the scroll strategy when the overlay is attached', () => {
321+
let overlayRef = overlay.create(config);
322+
323+
overlayRef.attach(componentPortal);
324+
expect(fakeScrollStrategy.enable).toHaveBeenCalled();
325+
});
326+
327+
it('should disable the scroll strategy once the overlay is detached', () => {
328+
let overlayRef = overlay.create(config);
329+
330+
overlayRef.attach(componentPortal);
331+
overlayRef.detach();
332+
333+
expect(fakeScrollStrategy.disable).toHaveBeenCalled();
334+
});
335+
336+
it('should disable the scroll strategy when the overlay is destroyed', () => {
337+
let overlayRef = overlay.create(config);
338+
339+
overlayRef.dispose();
340+
expect(fakeScrollStrategy.disable).toHaveBeenCalled();
341+
});
342+
});
298343
});
299344

300345
describe('OverlayContainer theming', () => {

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,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {inject, TestBed, async} from '@angular/core/testing';
2+
import {NgModule, Component} from '@angular/core';
3+
import {Subject} from 'rxjs/Subject';
4+
import {
5+
PortalModule,
6+
ComponentPortal,
7+
Overlay,
8+
OverlayState,
9+
OverlayRef,
10+
OverlayModule,
11+
ScrollStrategy,
12+
ScrollDispatcher,
13+
CloseScrollStrategy,
14+
} from '../../core';
15+
16+
17+
describe('CloseScrollStrategy', () => {
18+
let overlayRef: OverlayRef;
19+
let componentPortal: ComponentPortal<MozarellaMsg>;
20+
let scrolledSubject = new Subject();
21+
22+
beforeEach(async(() => {
23+
TestBed.configureTestingModule({
24+
imports: [OverlayModule, PortalModule, OverlayTestModule],
25+
providers: [
26+
{provide: ScrollDispatcher, useFactory: () => {
27+
return {scrolled: (delay: number, callback: () => any) => {
28+
return scrolledSubject.asObservable().subscribe(callback);
29+
}};
30+
}}
31+
]
32+
});
33+
34+
TestBed.compileComponents();
35+
}));
36+
37+
beforeEach(inject([Overlay, ScrollDispatcher], (overlay: Overlay,
38+
scrollDispatcher: ScrollDispatcher) => {
39+
40+
let overlayState = new OverlayState();
41+
overlayState.scrollStrategy = new CloseScrollStrategy(scrollDispatcher);
42+
overlayRef = overlay.create(overlayState);
43+
componentPortal = new ComponentPortal(MozarellaMsg);
44+
}));
45+
46+
it('should detach the overlay as soon as the user scrolls', () => {
47+
overlayRef.attach(componentPortal);
48+
spyOn(overlayRef, 'detach');
49+
50+
scrolledSubject.next();
51+
expect(overlayRef.detach).toHaveBeenCalled();
52+
});
53+
54+
it('should not attempt to detach the overlay after it has been detached', () => {
55+
overlayRef.attach(componentPortal);
56+
overlayRef.detach();
57+
58+
spyOn(overlayRef, 'detach');
59+
scrolledSubject.next();
60+
61+
expect(overlayRef.detach).not.toHaveBeenCalled();
62+
});
63+
64+
});
65+
66+
67+
/** Simple component that we can attach to the overlay. */
68+
@Component({template: '<p>Mozarella</p>'})
69+
class MozarellaMsg { }
70+
71+
72+
/** Test module to hold the component. */
73+
@NgModule({
74+
imports: [OverlayModule, PortalModule],
75+
declarations: [MozarellaMsg],
76+
entryComponents: [MozarellaMsg],
77+
})
78+
class OverlayTestModule { }
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {ScrollStrategy} from './scroll-strategy';
2+
import {OverlayRef} from '../overlay-ref';
3+
import {Subscription} from 'rxjs/Subscription';
4+
import {ScrollDispatcher} from './scroll-dispatcher';
5+
6+
7+
/**
8+
* Strategy that will close the overlay as soon as the user starts scrolling.
9+
*/
10+
export class CloseScrollStrategy implements ScrollStrategy {
11+
private _scrollSubscription: Subscription|null = null;
12+
private _overlayRef: OverlayRef;
13+
14+
constructor(private _scrollDispatcher) { }
15+
16+
attach(overlayRef: OverlayRef) {
17+
this._overlayRef = overlayRef;
18+
}
19+
20+
enable() {
21+
if (!this._scrollSubscription) {
22+
this._scrollSubscription = this._scrollDispatcher.scrolled(null, () => {
23+
if (this._overlayRef.hasAttached()) {
24+
this._overlayRef.detach();
25+
}
26+
27+
this.disable();
28+
});
29+
}
30+
}
31+
32+
disable() {
33+
if (this._scrollSubscription) {
34+
this._scrollSubscription.unsubscribe();
35+
this._scrollSubscription = null;
36+
}
37+
}
38+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {ScrollStrategy} from './scroll-strategy';
2+
3+
/**
4+
* Scroll strategy that doesn't do anything.
5+
*/
6+
export class NoopScrollStrategy implements ScrollStrategy {
7+
enable() { }
8+
disable() { }
9+
attach() { }
10+
}

0 commit comments

Comments
 (0)