Skip to content

Commit 5cd53be

Browse files
committed
feat(overlay): add scroll blocking strategy
Adds the `BlockScrollStrategy` which, when activated, will prevent the user from scrolling. For now it is only used in the dialog. Relates to #4093.
1 parent 1ec88e0 commit 5cd53be

File tree

6 files changed

+210
-2
lines changed

6 files changed

+210
-2
lines changed

src/lib/core/_core.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,11 @@
4141
.mat-theme-loaded-marker {
4242
display: none;
4343
}
44+
45+
// Used when disabling global scrolling.
46+
.cdk-global-scrollblock {
47+
position: fixed;
48+
overflow-y: scroll;
49+
max-width: 100vw; // necessary for iOS not to expand past the viewport.
50+
}
4451
}

src/lib/core/overlay/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ export {ScrollStrategy} from './scroll/scroll-strategy';
1818
export {RepositionScrollStrategy} from './scroll/reposition-scroll-strategy';
1919
export {CloseScrollStrategy} from './scroll/close-scroll-strategy';
2020
export {NoopScrollStrategy} from './scroll/noop-scroll-strategy';
21+
export {BlockScrollStrategy} from './scroll/block-scroll-strategy';

src/lib/core/overlay/position/viewport-ruler.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ export class ViewportRuler {
6666
// `scrollTop` and `scrollLeft` is inconsistent. However, using the bounding rect of
6767
// `document.documentElement` works consistently, where the `top` and `left` values will
6868
// equal negative the scroll position.
69-
const top = -documentRect.top || document.body.scrollTop || window.scrollY || 0;
70-
const left = -documentRect.left || document.body.scrollLeft || window.scrollX || 0;
69+
const top = -documentRect.top || document.body.scrollTop || window.scrollY ||
70+
document.documentElement.scrollTop || 0;
71+
72+
const left = -documentRect.left || document.body.scrollLeft || window.scrollX ||
73+
document.documentElement.scrollLeft || 0;
7174

7275
return {top, left};
7376
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {inject, TestBed, async} from '@angular/core/testing';
2+
import {ComponentPortal, OverlayModule, BlockScrollStrategy, Platform} from '../../core';
3+
import {ViewportRuler} from '../position/viewport-ruler';
4+
5+
6+
describe('BlockScrollStrategy', () => {
7+
let platform = new Platform();
8+
let strategy: BlockScrollStrategy;
9+
let viewport: ViewportRuler;
10+
let forceScrollElement: HTMLElement;
11+
12+
beforeEach(async(() => {
13+
TestBed.configureTestingModule({ imports: [OverlayModule] }).compileComponents();
14+
}));
15+
16+
beforeEach(inject([ViewportRuler], (_viewportRuler: ViewportRuler) => {
17+
strategy = new BlockScrollStrategy(_viewportRuler);
18+
viewport = _viewportRuler;
19+
forceScrollElement = document.createElement('div');
20+
document.body.appendChild(forceScrollElement);
21+
forceScrollElement.style.width = '100px';
22+
forceScrollElement.style.height = '3000px';
23+
}));
24+
25+
afterEach(() => {
26+
document.body.removeChild(forceScrollElement);
27+
setScrollPosition(0, 0);
28+
});
29+
30+
it('should toggle scroll blocking along the y axis', skipUnreliableBrowser(() => {
31+
forceScrollElement.style.height = '3000px';
32+
33+
setScrollPosition(0, 100);
34+
expect(viewport.getViewportScrollPosition().top).toBe(100,
35+
'Expected viewport to be scrollable initially.');
36+
37+
strategy.enable();
38+
expect(document.documentElement.style.top).toBe('-100px',
39+
'Expected <html> element to be offset by the previous scroll amount along the y axis.');
40+
41+
setScrollPosition(0, 300);
42+
expect(viewport.getViewportScrollPosition().top).toBe(100,
43+
'Expected the viewport not to scroll.');
44+
45+
strategy.disable();
46+
expect(viewport.getViewportScrollPosition().top).toBe(100,
47+
'Expected old scroll position to have bee restored after disabling.');
48+
49+
setScrollPosition(0, 300);
50+
expect(viewport.getViewportScrollPosition().top).toBe(300,
51+
'Expected user to be able to scroll after disabling.');
52+
}));
53+
54+
55+
it('should toggle scroll blocking along the x axis', skipUnreliableBrowser(() => {
56+
forceScrollElement.style.width = '3000px';
57+
58+
setScrollPosition(100, 0);
59+
expect(viewport.getViewportScrollPosition().left).toBe(100,
60+
'Expected viewport to be scrollable initially.');
61+
62+
strategy.enable();
63+
expect(document.documentElement.style.left).toBe('-100px',
64+
'Expected <html> element to be offset by the previous scroll amount along the x axis.');
65+
66+
setScrollPosition(300, 0);
67+
expect(viewport.getViewportScrollPosition().left).toBe(100,
68+
'Expected the viewport not to scroll.');
69+
70+
strategy.disable();
71+
expect(viewport.getViewportScrollPosition().left).toBe(100,
72+
'Expected old scroll position to have bee restored after disabling.');
73+
74+
setScrollPosition(300, 0);
75+
expect(viewport.getViewportScrollPosition().left).toBe(300,
76+
'Expected user to be able to scroll after disabling.');
77+
}));
78+
79+
80+
it('should toggle the `cdk-global-scrollblock` class', skipUnreliableBrowser(() => {
81+
forceScrollElement.style.height = '3000px';
82+
83+
expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock');
84+
85+
strategy.enable();
86+
expect(document.documentElement.classList).toContain('cdk-global-scrollblock');
87+
88+
strategy.disable();
89+
expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock');
90+
}));
91+
92+
it('should restore any previously-set inline styles', skipUnreliableBrowser(() => {
93+
const root = document.documentElement;
94+
95+
forceScrollElement.style.height = '3000px';
96+
root.style.top = '13px';
97+
root.style.left = '37px';
98+
99+
strategy.enable();
100+
101+
expect(root.style.top).not.toBe('13px');
102+
expect(root.style.left).not.toBe('37px');
103+
104+
strategy.disable();
105+
106+
expect(root.style.top).toBe('13px');
107+
expect(root.style.left).toBe('37px');
108+
}));
109+
110+
it(`should't do anything if the page isn't scrollable`, skipUnreliableBrowser(() => {
111+
forceScrollElement.style.display = 'none';
112+
strategy.enable();
113+
expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock');
114+
}));
115+
116+
117+
// In the iOS simulator (BrowserStack & SauceLabs), adding content to the
118+
// body causes karma's iframe for the test to stretch to fit that content,
119+
// in addition to not allowing us to set the scroll position programmatically.
120+
// This renders the tests unusable and since we can't really do anything about it,
121+
// we have to skip them on iOS.
122+
function skipUnreliableBrowser(spec: Function) {
123+
return () => {
124+
if (!platform.IOS) { spec(); }
125+
};
126+
}
127+
128+
function setScrollPosition(x: number, y: number) {
129+
window.scroll(x, y);
130+
viewport._cacheViewportGeometry();
131+
}
132+
133+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {ScrollStrategy} from './scroll-strategy';
2+
import {ViewportRuler} from '../position/viewport-ruler';
3+
4+
/**
5+
* Strategy that will prevent the user from scrolling while the overlay is visible.
6+
*/
7+
export class BlockScrollStrategy implements ScrollStrategy {
8+
private _prevHTMLStyles = { top: null, left: null };
9+
private _previousScrollPosition: { top: number, left: number };
10+
private _isEnabled = false;
11+
12+
constructor(private _viewportRuler: ViewportRuler) { }
13+
14+
attach() { }
15+
16+
enable() {
17+
if (this._canBeEnabled()) {
18+
const root = document.documentElement;
19+
20+
this._previousScrollPosition = this._viewportRuler.getViewportScrollPosition();
21+
22+
// Cache the previous inline styles in case the user had set them.
23+
this._prevHTMLStyles.left = root.style.left;
24+
this._prevHTMLStyles.top = root.style.top;
25+
26+
// Note: we're using the `html` node, instead of the `body`, because the `body` may
27+
// have the user agent margin, whereas the `html` is guaranteed not to have one.
28+
root.style.left = `${-this._previousScrollPosition.left}px`;
29+
root.style.top = `${-this._previousScrollPosition.top}px`;
30+
root.classList.add('cdk-global-scrollblock');
31+
this._isEnabled = true;
32+
}
33+
}
34+
35+
disable() {
36+
if (this._isEnabled) {
37+
this._isEnabled = false;
38+
document.documentElement.style.left = this._prevHTMLStyles.left;
39+
document.documentElement.style.top = this._prevHTMLStyles.top;
40+
document.documentElement.classList.remove('cdk-global-scrollblock');
41+
window.scroll(this._previousScrollPosition.left, this._previousScrollPosition.top);
42+
}
43+
}
44+
45+
private _canBeEnabled(): boolean {
46+
// Since the scroll strategies can't be singletons, we have to use a global CSS class
47+
// (`cdk-global-scrollblock`) to make sure that we don't try to disable global
48+
// scrolling multiple times.
49+
const isBlockedAlready =
50+
document.documentElement.classList.contains('cdk-global-scrollblock') || this._isEnabled;
51+
52+
if (!isBlockedAlready) {
53+
const body = document.body;
54+
const viewport = this._viewportRuler.getViewportRect();
55+
return body.scrollHeight > viewport.height || body.scrollWidth > viewport.width;
56+
}
57+
58+
return false;
59+
}
60+
}

src/lib/dialog/dialog.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {MdDialogConfig} from './dialog-config';
1010
import {MdDialogRef} from './dialog-ref';
1111
import {MdDialogContainer} from './dialog-container';
1212
import {TemplatePortal} from '../core/portal/portal';
13+
import {BlockScrollStrategy} from '../core/overlay/scroll/block-scroll-strategy';
14+
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
1315
import 'rxjs/add/operator/first';
1416

1517

@@ -48,6 +50,7 @@ export class MdDialog {
4850
constructor(
4951
private _overlay: Overlay,
5052
private _injector: Injector,
53+
private _viewportRuler: ViewportRuler,
5154
@Optional() private _location: Location,
5255
@Optional() @SkipSelf() private _parentDialog: MdDialog) {
5356

@@ -119,6 +122,7 @@ export class MdDialog {
119122
private _getOverlayState(dialogConfig: MdDialogConfig): OverlayState {
120123
let overlayState = new OverlayState();
121124
overlayState.hasBackdrop = dialogConfig.hasBackdrop;
125+
overlayState.scrollStrategy = new BlockScrollStrategy(this._viewportRuler);
122126
if (dialogConfig.backdropClass) {
123127
overlayState.backdropClass = dialogConfig.backdropClass;
124128
}

0 commit comments

Comments
 (0)