feat(overlay): add scroll blocking strategy (#4500)

Adds the `BlockScrollStrategy` which, when activated, will prevent the user from scrolling. For now it is only used in the dialog.

Relates to #4093.
This commit is contained in:
Kristiyan Kostadinov
2017-05-15 23:28:30 +02:00
committed by Jeremy Elbourn
parent 0666207527
commit 6842046979
16 changed files with 435 additions and 4 deletions
@@ -0,0 +1,134 @@
import {browser, Key, element, by} from 'protractor';
import {screenshot} from '../../screenshot';
import {getScrollPosition} from '../../util/query';
describe('scroll blocking', () => {
beforeEach(() => browser.get('/block-scroll-strategy'));
afterEach(() => clickOn('disable'));
it('should not be able to scroll programmatically along the x axis', async (done) => {
scrollPage(0, 100);
expect((await getScrollPosition()).y).toBe(100, 'Expected the page to be scrollable.');
clickOn('enable');
scrollPage(0, 200);
expect((await getScrollPosition()).y).toBe(100, 'Expected the page not to be scrollable.');
clickOn('disable');
scrollPage(0, 300);
expect((await getScrollPosition()).y).toBe(300, 'Exected page to be scrollable again.');
screenshot();
done();
});
it('should not be able to scroll programmatically along the y axis', async (done) => {
scrollPage(100, 0);
expect((await getScrollPosition()).x).toBe(100, 'Expected the page to be scrollable.');
clickOn('enable');
scrollPage(200, 0);
expect((await getScrollPosition()).x).toBe(100, 'Expected the page not to be scrollable.');
clickOn('disable');
scrollPage(300, 0);
expect((await getScrollPosition()).x).toBe(300, 'Exected page to be scrollable again.');
screenshot();
done();
});
it('should not be able to scroll via the keyboard along the y axis', async (done) => {
const body = element(by.tagName('body'));
scrollPage(0, 100);
expect((await getScrollPosition()).y).toBe(100, 'Expected the page to be scrollable.');
clickOn('enable');
await body.sendKeys(Key.ARROW_DOWN);
await body.sendKeys(Key.ARROW_DOWN);
await body.sendKeys(Key.ARROW_DOWN);
expect((await getScrollPosition()).y).toBe(100, 'Expected the page not to be scrollable.');
clickOn('disable');
await body.sendKeys(Key.ARROW_DOWN);
await body.sendKeys(Key.ARROW_DOWN);
await body.sendKeys(Key.ARROW_DOWN);
expect((await getScrollPosition()).y)
.toBeGreaterThan(100, 'Expected the page to be scrollable again.');
screenshot();
done();
});
it('should not be able to scroll via the keyboard along the x axis', async (done) => {
const body = element(by.tagName('body'));
scrollPage(100, 0);
expect((await getScrollPosition()).x).toBe(100, 'Expected the page to be scrollable.');
clickOn('enable');
await body.sendKeys(Key.ARROW_RIGHT);
await body.sendKeys(Key.ARROW_RIGHT);
await body.sendKeys(Key.ARROW_RIGHT);
expect((await getScrollPosition()).x).toBe(100, 'Expected the page not to be scrollable.');
clickOn('disable');
await body.sendKeys(Key.ARROW_RIGHT);
await body.sendKeys(Key.ARROW_RIGHT);
await body.sendKeys(Key.ARROW_RIGHT);
expect((await getScrollPosition()).x)
.toBeGreaterThan(100, 'Expected the page to be scrollable again.');
screenshot();
done();
});
it('should not be able to scroll the page after reaching the end of an element along the y axis',
async (done) => {
const scroller = element(by.id('scroller'));
browser.executeScript(`document.getElementById('scroller').scrollTop = 200;`);
scrollPage(0, 100);
expect((await getScrollPosition()).y).toBe(100, 'Expected the page to be scrollable.');
clickOn('enable');
scroller.sendKeys(Key.ARROW_DOWN);
scroller.sendKeys(Key.ARROW_DOWN);
scroller.sendKeys(Key.ARROW_DOWN);
expect((await getScrollPosition()).y).toBe(100, 'Expected the page not to have scrolled.');
screenshot();
done();
});
it('should not be able to scroll the page after reaching the end of an element along the x axis',
async (done) => {
const scroller = element(by.id('scroller'));
browser.executeScript(`document.getElementById('scroller').scrollLeft = 200;`);
scrollPage(100, 0);
expect((await getScrollPosition()).x).toBe(100, 'Expected the page to be scrollable.');
clickOn('enable');
scroller.sendKeys(Key.ARROW_RIGHT);
scroller.sendKeys(Key.ARROW_RIGHT);
scroller.sendKeys(Key.ARROW_RIGHT);
expect((await getScrollPosition()).x).toBe(100, 'Expected the page not to have scrolled.');
screenshot();
done();
});
});
// Clicks on a button programmatically. Note that we can't use Protractor's `.click`, because
// it performs a real click, which will scroll the button into view.
function clickOn(id: string) {
browser.executeScript(`document.getElementById('${id}').click()`);
}
// Scrolls the page to the specified coordinates.
function scrollPage(x: number, y: number) {
return browser.executeScript(`window.scrollTo(${x}, ${y});`);
}
+1
View File
@@ -6,6 +6,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"inlineSources": true,
"lib": ["es2015"],
"module": "commonjs",
"moduleResolution": "node",
"noEmitOnError": true,
+1 -1
View File
@@ -20,7 +20,7 @@ export function expectFocusOn(element: FinderResult, expected = true): void {
}
/**
* Asserts that an element has a certan location.
* Asserts that an element has a certain location.
*/
export function expectLocation(element: FinderResult, {x, y}: Point): void {
getElement(element).getLocation().then((location: Point) => {
+18
View File
@@ -1,4 +1,5 @@
import {ElementFinder, by, element, ProtractorBy, browser} from 'protractor';
import {Point} from './actions';
/**
* Normalizes either turning a selector into an
@@ -15,4 +16,21 @@ export function waitForElement(selector: string) {
return browser.isElementPresent(by.css(selector) as ProtractorBy);
}
/**
* Determines the current scroll position of the page.
*/
export async function getScrollPosition(): Promise<Point> {
const snippet = `
var documentRect = document.documentElement.getBoundingClientRect();
var x = -documentRect.left || document.body.scrollLeft || window.scrollX ||
document.documentElement.scrollLeft || 0;
var y = -documentRect.top || document.body.scrollTop || window.scrollY ||
document.documentElement.scrollTop || 0;
return {x: x, y: y};
`;
return await browser.executeScript<Point>(snippet);
}
export type FinderResult = ElementFinder | string;
@@ -0,0 +1,29 @@
.spacer {
background: #3f51b5;
margin-bottom: 10px;
}
.spacer.vertical {
width: 100px;
height: 3000px;
}
.spacer.horizontal {
width: 3000px;
height: 100px;
}
.scroller {
width: 100px;
height: 100px;
overflow: auto;
position: absolute;
top: 100px;
left: 200px;
}
.scroller-spacer {
width: 200px;
height: 200px;
background: #ff4081;
}
@@ -0,0 +1,10 @@
<p>
<button id="enable" (click)="scrollStrategy.enable()">Enable scroll blocking</button>
<button id="disable" (click)="scrollStrategy.disable()">Disable scroll blocking</button>
</p>
<div class="spacer vertical"></div>
<!-- this one needs a tabindex so protractor can trigger key presses inside it -->
<div class="scroller" id="scroller" tabindex="-1">
<div class="scroller-spacer"></div>
</div>
<div class="spacer horizontal"></div>
@@ -0,0 +1,13 @@
import {Component} from '@angular/core';
import {BlockScrollStrategy, ViewportRuler} from '@angular/material';
@Component({
moduleId: module.id,
selector: 'block-scroll-strategy-e2e',
templateUrl: 'block-scroll-strategy-e2e.html',
styleUrls: ['block-scroll-strategy-e2e.css'],
})
export class BlockScrollStrategyE2E {
constructor(private _viewportRuler: ViewportRuler) { }
scrollStrategy = new BlockScrollStrategy(this._viewportRuler);
}
+3 -1
View File
@@ -19,6 +19,7 @@ import {MaterialModule, OverlayContainer, FullscreenOverlayContainer} from '@ang
import {E2E_APP_ROUTES} from './e2e-app/routes';
import {SlideToggleE2E} from './slide-toggle/slide-toggle-e2e';
import {InputE2E} from './input/input-e2e';
import {BlockScrollStrategyE2E} from './block-scroll-strategy/block-scroll-strategy-e2e';
@NgModule({
imports: [
@@ -45,7 +46,8 @@ import {InputE2E} from './input/input-e2e';
SimpleRadioButtons,
SlideToggleE2E,
TestDialog,
TestDialogFullScreen
TestDialogFullScreen,
BlockScrollStrategyE2E
],
bootstrap: [E2EApp],
providers: [
+1
View File
@@ -1,6 +1,7 @@
<button (click)="showLinks = !showLinks">Toggle Navigation Links</button>
<md-nav-list *ngIf="showLinks">
<a md-list-item [routerLink]="['block-scroll-strategy']">Block scroll strategy</a>
<a md-list-item [routerLink]="['button']">Button</a>
<a md-list-item [routerLink]="['checkbox']">Checkbox</a>
<a md-list-item [routerLink]="['dialog']">Dialog</a>
+2
View File
@@ -14,9 +14,11 @@ import {ProgressSpinnerE2E} from '../progress-spinner/progress-spinner-e2e';
import {SlideToggleE2E} from '../slide-toggle/slide-toggle-e2e';
import {FullscreenE2E} from '../fullscreen/fullscreen-e2e';
import {InputE2E} from '../input/input-e2e';
import {BlockScrollStrategyE2E} from '../block-scroll-strategy/block-scroll-strategy-e2e';
export const E2E_APP_ROUTES: Routes = [
{path: '', component: Home},
{path: 'block-scroll-strategy', component: BlockScrollStrategyE2E},
{path: 'button', component: ButtonE2E},
{path: 'checkbox', component: SimpleCheckboxes},
{path: 'dialog', component: DialogE2E},
+13
View File
@@ -67,4 +67,17 @@
.cdk-overlay-transparent-backdrop {
background: none;
}
// Used when disabling global scrolling.
.cdk-global-scrollblock {
position: fixed;
// Necessary for iOS not to expand past the viewport.
max-width: 100vw;
// Note: this will always add a scrollbar to whatever element it is on, which can
// potentially result in double scrollbars. It shouldn't be an issue, because we won't
// block scrolling on a page that doesn't have a scrollbar in the first place.
overflow-y: scroll;
}
}
+2
View File
@@ -5,6 +5,7 @@ export {OverlayRef} from './overlay-ref';
export {OverlayState} from './overlay-state';
export {ConnectedOverlayDirective, OverlayOrigin, OverlayModule} from './overlay-directives';
export {ScrollDispatcher} from './scroll/scroll-dispatcher';
export {ViewportRuler} from './position/viewport-ruler';
export * from './position/connected-position';
@@ -18,3 +19,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';
@@ -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};
}
@@ -0,0 +1,142 @@
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', skipIOS(() => {
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.');
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', skipIOS(() => {
forceScrollElement.style.height = '100px';
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.');
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', skipIOS(() => {
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', skipIOS(() => {
const root = document.documentElement;
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`, skipIOS(() => {
forceScrollElement.style.display = 'none';
strategy.enable();
expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock');
}));
/**
* Skips the specified test, if it is being executed on iOS. This is necessary, because
* programmatic scrolling inside the Karma iframe doesn't work on iOS, which renders these
* tests unusable. For example, something as basic as the following won't work:
* ```
* window.scroll(0, 100);
* viewport._cacheViewportGeometry();
* expect(viewport.getViewportScrollPosition().top).toBe(100);
* ```
* @param spec Test to be executed or skipped.
*/
function skipIOS(spec: Function) {
return () => {
if (!platform.IOS) {
spec();
}
};
}
/**
* Scrolls the viewport and clears the cache.
* @param x Amount to scroll along the x axis.
* @param y Amount to scroll along the y axis.
*/
function setScrollPosition(x: number, y: number) {
window.scroll(x, y);
viewport._cacheViewportGeometry();
}
});
@@ -0,0 +1,57 @@
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 _previousHTMLStyles = { 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._previousHTMLStyles.left = root.style.left;
this._previousHTMLStyles.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._previousHTMLStyles.left;
document.documentElement.style.top = this._previousHTMLStyles.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.
if (document.documentElement.classList.contains('cdk-global-scrollblock') || this._isEnabled) {
return false;
}
const body = document.body;
const viewport = this._viewportRuler.getViewportRect();
return body.scrollHeight > viewport.height || body.scrollWidth > viewport.width;
}
}
+4
View File
@@ -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';
@@ -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) {
@@ -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;
}