Skip to content

Commit

Permalink
feat(overlay): add scroll blocking strategy
Browse files Browse the repository at this point in the history
Adds the `BlockScrollStrategy` which, when activated, will prevent the user from scrolling. For now it is only used in the dialog.

Relates to angular#4093.
  • Loading branch information
crisbeto committed May 12, 2017
1 parent 1ec88e0 commit 5cd53be
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 2 deletions.
7 changes: 7 additions & 0 deletions src/lib/core/_core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,11 @@
.mat-theme-loaded-marker {
display: none;
}

// Used when disabling global scrolling.
.cdk-global-scrollblock {
position: fixed;
overflow-y: scroll;
max-width: 100vw; // necessary for iOS not to expand past the viewport.
}
}
1 change: 1 addition & 0 deletions src/lib/core/overlay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export {ScrollStrategy} from './scroll/scroll-strategy';
export {RepositionScrollStrategy} from './scroll/reposition-scroll-strategy';
export {CloseScrollStrategy} from './scroll/close-scroll-strategy';
export {NoopScrollStrategy} from './scroll/noop-scroll-strategy';
export {BlockScrollStrategy} from './scroll/block-scroll-strategy';
7 changes: 5 additions & 2 deletions src/lib/core/overlay/position/viewport-ruler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@ export class ViewportRuler {
// `scrollTop` and `scrollLeft` is inconsistent. However, using the bounding rect of
// `document.documentElement` works consistently, where the `top` and `left` values will
// equal negative the scroll position.
const top = -documentRect.top || document.body.scrollTop || window.scrollY || 0;
const left = -documentRect.left || document.body.scrollLeft || window.scrollX || 0;
const top = -documentRect.top || document.body.scrollTop || window.scrollY ||
document.documentElement.scrollTop || 0;

const left = -documentRect.left || document.body.scrollLeft || window.scrollX ||
document.documentElement.scrollLeft || 0;

return {top, left};
}
Expand Down
133 changes: 133 additions & 0 deletions src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {inject, TestBed, async} from '@angular/core/testing';
import {ComponentPortal, OverlayModule, BlockScrollStrategy, Platform} from '../../core';
import {ViewportRuler} from '../position/viewport-ruler';


describe('BlockScrollStrategy', () => {
let platform = new Platform();
let strategy: BlockScrollStrategy;
let viewport: ViewportRuler;
let forceScrollElement: HTMLElement;

beforeEach(async(() => {
TestBed.configureTestingModule({ imports: [OverlayModule] }).compileComponents();
}));

beforeEach(inject([ViewportRuler], (_viewportRuler: ViewportRuler) => {
strategy = new BlockScrollStrategy(_viewportRuler);
viewport = _viewportRuler;
forceScrollElement = document.createElement('div');
document.body.appendChild(forceScrollElement);
forceScrollElement.style.width = '100px';
forceScrollElement.style.height = '3000px';
}));

afterEach(() => {
document.body.removeChild(forceScrollElement);
setScrollPosition(0, 0);
});

it('should toggle scroll blocking along the y axis', skipUnreliableBrowser(() => {
forceScrollElement.style.height = '3000px';

setScrollPosition(0, 100);
expect(viewport.getViewportScrollPosition().top).toBe(100,
'Expected viewport to be scrollable initially.');

strategy.enable();
expect(document.documentElement.style.top).toBe('-100px',
'Expected <html> element to be offset by the previous scroll amount along the y axis.');

setScrollPosition(0, 300);
expect(viewport.getViewportScrollPosition().top).toBe(100,
'Expected the viewport not to scroll.');

strategy.disable();
expect(viewport.getViewportScrollPosition().top).toBe(100,
'Expected old scroll position to have bee restored after disabling.');

setScrollPosition(0, 300);
expect(viewport.getViewportScrollPosition().top).toBe(300,
'Expected user to be able to scroll after disabling.');
}));


it('should toggle scroll blocking along the x axis', skipUnreliableBrowser(() => {
forceScrollElement.style.width = '3000px';

setScrollPosition(100, 0);
expect(viewport.getViewportScrollPosition().left).toBe(100,
'Expected viewport to be scrollable initially.');

strategy.enable();
expect(document.documentElement.style.left).toBe('-100px',
'Expected <html> element to be offset by the previous scroll amount along the x axis.');

setScrollPosition(300, 0);
expect(viewport.getViewportScrollPosition().left).toBe(100,
'Expected the viewport not to scroll.');

strategy.disable();
expect(viewport.getViewportScrollPosition().left).toBe(100,
'Expected old scroll position to have bee restored after disabling.');

setScrollPosition(300, 0);
expect(viewport.getViewportScrollPosition().left).toBe(300,
'Expected user to be able to scroll after disabling.');
}));


it('should toggle the `cdk-global-scrollblock` class', skipUnreliableBrowser(() => {
forceScrollElement.style.height = '3000px';

expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock');

strategy.enable();
expect(document.documentElement.classList).toContain('cdk-global-scrollblock');

strategy.disable();
expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock');
}));

it('should restore any previously-set inline styles', skipUnreliableBrowser(() => {
const root = document.documentElement;

forceScrollElement.style.height = '3000px';
root.style.top = '13px';
root.style.left = '37px';

strategy.enable();

expect(root.style.top).not.toBe('13px');
expect(root.style.left).not.toBe('37px');

strategy.disable();

expect(root.style.top).toBe('13px');
expect(root.style.left).toBe('37px');
}));

it(`should't do anything if the page isn't scrollable`, skipUnreliableBrowser(() => {
forceScrollElement.style.display = 'none';
strategy.enable();
expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock');
}));


// In the iOS simulator (BrowserStack & SauceLabs), adding content to the
// body causes karma's iframe for the test to stretch to fit that content,
// in addition to not allowing us to set the scroll position programmatically.
// This renders the tests unusable and since we can't really do anything about it,
// we have to skip them on iOS.
function skipUnreliableBrowser(spec: Function) {
return () => {
if (!platform.IOS) { spec(); }
};
}

function setScrollPosition(x: number, y: number) {
window.scroll(x, y);
viewport._cacheViewportGeometry();
}

});
60 changes: 60 additions & 0 deletions src/lib/core/overlay/scroll/block-scroll-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {ScrollStrategy} from './scroll-strategy';
import {ViewportRuler} from '../position/viewport-ruler';

/**
* Strategy that will prevent the user from scrolling while the overlay is visible.
*/
export class BlockScrollStrategy implements ScrollStrategy {
private _prevHTMLStyles = { top: null, left: null };
private _previousScrollPosition: { top: number, left: number };
private _isEnabled = false;

constructor(private _viewportRuler: ViewportRuler) { }

attach() { }

enable() {
if (this._canBeEnabled()) {
const root = document.documentElement;

this._previousScrollPosition = this._viewportRuler.getViewportScrollPosition();

// Cache the previous inline styles in case the user had set them.
this._prevHTMLStyles.left = root.style.left;
this._prevHTMLStyles.top = root.style.top;

// Note: we're using the `html` node, instead of the `body`, because the `body` may
// have the user agent margin, whereas the `html` is guaranteed not to have one.
root.style.left = `${-this._previousScrollPosition.left}px`;
root.style.top = `${-this._previousScrollPosition.top}px`;
root.classList.add('cdk-global-scrollblock');
this._isEnabled = true;
}
}

disable() {
if (this._isEnabled) {
this._isEnabled = false;
document.documentElement.style.left = this._prevHTMLStyles.left;
document.documentElement.style.top = this._prevHTMLStyles.top;
document.documentElement.classList.remove('cdk-global-scrollblock');
window.scroll(this._previousScrollPosition.left, this._previousScrollPosition.top);
}
}

private _canBeEnabled(): boolean {
// Since the scroll strategies can't be singletons, we have to use a global CSS class
// (`cdk-global-scrollblock`) to make sure that we don't try to disable global
// scrolling multiple times.
const isBlockedAlready =
document.documentElement.classList.contains('cdk-global-scrollblock') || this._isEnabled;

if (!isBlockedAlready) {
const body = document.body;
const viewport = this._viewportRuler.getViewportRect();
return body.scrollHeight > viewport.height || body.scrollWidth > viewport.width;
}

return false;
}
}
4 changes: 4 additions & 0 deletions src/lib/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {MdDialogConfig} from './dialog-config';
import {MdDialogRef} from './dialog-ref';
import {MdDialogContainer} from './dialog-container';
import {TemplatePortal} from '../core/portal/portal';
import {BlockScrollStrategy} from '../core/overlay/scroll/block-scroll-strategy';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
import 'rxjs/add/operator/first';


Expand Down Expand Up @@ -48,6 +50,7 @@ export class MdDialog {
constructor(
private _overlay: Overlay,
private _injector: Injector,
private _viewportRuler: ViewportRuler,
@Optional() private _location: Location,
@Optional() @SkipSelf() private _parentDialog: MdDialog) {

Expand Down Expand Up @@ -119,6 +122,7 @@ export class MdDialog {
private _getOverlayState(dialogConfig: MdDialogConfig): OverlayState {
let overlayState = new OverlayState();
overlayState.hasBackdrop = dialogConfig.hasBackdrop;
overlayState.scrollStrategy = new BlockScrollStrategy(this._viewportRuler);
if (dialogConfig.backdropClass) {
overlayState.backdropClass = dialogConfig.backdropClass;
}
Expand Down

0 comments on commit 5cd53be

Please sign in to comment.