From 5cd53be688ebd4fe6bc8f4689a288a52c33dafc5 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 9 May 2017 19:29:56 +0200 Subject: [PATCH 1/4] 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. --- src/lib/core/_core.scss | 7 + src/lib/core/overlay/index.ts | 1 + .../core/overlay/position/viewport-ruler.ts | 7 +- .../scroll/block-scroll-strategy.spec.ts | 133 ++++++++++++++++++ .../overlay/scroll/block-scroll-strategy.ts | 60 ++++++++ src/lib/dialog/dialog.ts | 4 + 6 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts create mode 100644 src/lib/core/overlay/scroll/block-scroll-strategy.ts diff --git a/src/lib/core/_core.scss b/src/lib/core/_core.scss index 2eeb5405d386..52f3fb404e5d 100644 --- a/src/lib/core/_core.scss +++ b/src/lib/core/_core.scss @@ -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. + } } diff --git a/src/lib/core/overlay/index.ts b/src/lib/core/overlay/index.ts index 07fee5e728df..98feccf0c421 100644 --- a/src/lib/core/overlay/index.ts +++ b/src/lib/core/overlay/index.ts @@ -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'; diff --git a/src/lib/core/overlay/position/viewport-ruler.ts b/src/lib/core/overlay/position/viewport-ruler.ts index 8fb7b87e769f..079316e0c4d0 100644 --- a/src/lib/core/overlay/position/viewport-ruler.ts +++ b/src/lib/core/overlay/position/viewport-ruler.ts @@ -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}; } diff --git a/src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts b/src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts new file mode 100644 index 000000000000..7f9b37491621 --- /dev/null +++ b/src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts @@ -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 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 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(); + } + +}); diff --git a/src/lib/core/overlay/scroll/block-scroll-strategy.ts b/src/lib/core/overlay/scroll/block-scroll-strategy.ts new file mode 100644 index 000000000000..99d3e932d11e --- /dev/null +++ b/src/lib/core/overlay/scroll/block-scroll-strategy.ts @@ -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; + } +} diff --git a/src/lib/dialog/dialog.ts b/src/lib/dialog/dialog.ts index 147352792449..a4c275bdf6e3 100644 --- a/src/lib/dialog/dialog.ts +++ b/src/lib/dialog/dialog.ts @@ -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; } From caaeb8d6ec6c5ef02716ce18d820d370bd7e342e Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 13 May 2017 11:27:26 +0200 Subject: [PATCH 2/4] chore: address feedback --- src/lib/core/_core.scss | 8 +- .../scroll/block-scroll-strategy.spec.ts | 91 ++++++++++--------- .../overlay/scroll/block-scroll-strategy.ts | 23 ++--- 3 files changed, 67 insertions(+), 55 deletions(-) diff --git a/src/lib/core/_core.scss b/src/lib/core/_core.scss index 52f3fb404e5d..4d6f5b10f39c 100644 --- a/src/lib/core/_core.scss +++ b/src/lib/core/_core.scss @@ -45,7 +45,13 @@ // 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; - max-width: 100vw; // necessary for iOS not to expand past the viewport. } } diff --git a/src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts b/src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts index 7f9b37491621..742ab31ab4d7 100644 --- a/src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts +++ b/src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts @@ -10,12 +10,12 @@ describe('BlockScrollStrategy', () => { let forceScrollElement: HTMLElement; beforeEach(async(() => { - TestBed.configureTestingModule({ imports: [OverlayModule] }).compileComponents(); + TestBed.configureTestingModule({imports: [OverlayModule]}).compileComponents(); })); - beforeEach(inject([ViewportRuler], (_viewportRuler: ViewportRuler) => { - strategy = new BlockScrollStrategy(_viewportRuler); - viewport = _viewportRuler; + beforeEach(inject([ViewportRuler], (viewportRuler: ViewportRuler) => { + strategy = new BlockScrollStrategy(viewportRuler); + viewport = viewportRuler; forceScrollElement = document.createElement('div'); document.body.appendChild(forceScrollElement); forceScrollElement.style.width = '100px'; @@ -27,59 +27,56 @@ describe('BlockScrollStrategy', () => { setScrollPosition(0, 0); }); - it('should toggle scroll blocking along the y axis', skipUnreliableBrowser(() => { - forceScrollElement.style.height = '3000px'; - + 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.'); + expect(viewport.getViewportScrollPosition().top) + .toBe(100, 'Expected viewport to be scrollable initially.'); strategy.enable(); - expect(document.documentElement.style.top).toBe('-100px', - 'Expected element to be offset by the previous scroll amount along the y axis.'); + expect(document.documentElement.style.top) + .toBe('-100px', 'Expected element to be offset by the previous scroll amount.'); setScrollPosition(0, 300); - expect(viewport.getViewportScrollPosition().top).toBe(100, - 'Expected the viewport not to scroll.'); + 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.'); + 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.'); + 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(() => { + 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.'); + expect(viewport.getViewportScrollPosition().left) + .toBe(100, 'Expected viewport to be scrollable initially.'); strategy.enable(); - expect(document.documentElement.style.left).toBe('-100px', - 'Expected element to be offset by the previous scroll amount along the x axis.'); + expect(document.documentElement.style.left) + .toBe('-100px', 'Expected element to be offset by the previous scroll amount.'); setScrollPosition(300, 0); - expect(viewport.getViewportScrollPosition().left).toBe(100, - 'Expected the viewport not to scroll.'); + 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.'); + 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.'); + 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'; - + it('should toggle the `cdk-global-scrollblock` class', skipIOS(() => { expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock'); strategy.enable(); @@ -89,10 +86,9 @@ describe('BlockScrollStrategy', () => { expect(document.documentElement.classList).not.toContain('cdk-global-scrollblock'); })); - it('should restore any previously-set inline styles', skipUnreliableBrowser(() => { + it('should restore any previously-set inline styles', skipIOS(() => { const root = document.documentElement; - forceScrollElement.style.height = '3000px'; root.style.top = '13px'; root.style.left = '37px'; @@ -107,24 +103,37 @@ describe('BlockScrollStrategy', () => { expect(root.style.left).toBe('37px'); })); - it(`should't do anything if the page isn't scrollable`, skipUnreliableBrowser(() => { + 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'); })); - // 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) { + /** + * 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(); } + 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(); diff --git a/src/lib/core/overlay/scroll/block-scroll-strategy.ts b/src/lib/core/overlay/scroll/block-scroll-strategy.ts index 99d3e932d11e..438db2efb560 100644 --- a/src/lib/core/overlay/scroll/block-scroll-strategy.ts +++ b/src/lib/core/overlay/scroll/block-scroll-strategy.ts @@ -5,7 +5,7 @@ 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 _previousHTMLStyles = { top: null, left: null }; private _previousScrollPosition: { top: number, left: number }; private _isEnabled = false; @@ -20,8 +20,8 @@ export class BlockScrollStrategy implements ScrollStrategy { 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; + 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. @@ -35,8 +35,8 @@ export class BlockScrollStrategy implements ScrollStrategy { disable() { if (this._isEnabled) { this._isEnabled = false; - document.documentElement.style.left = this._prevHTMLStyles.left; - document.documentElement.style.top = this._prevHTMLStyles.top; + 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); } @@ -46,15 +46,12 @@ export class BlockScrollStrategy implements ScrollStrategy { // 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; + if (document.documentElement.classList.contains('cdk-global-scrollblock') || this._isEnabled) { + return false; } - return false; + const body = document.body; + const viewport = this._viewportRuler.getViewportRect(); + return body.scrollHeight > viewport.height || body.scrollWidth > viewport.width; } } From c861b218ac66ea3a601bc9895afa552bae4e8e69 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 13 May 2017 15:52:40 +0200 Subject: [PATCH 3/4] test: add e2e tests for the scroll blocking --- .../block-scroll-strategy.e2e.ts | 134 ++++++++++++++++++ e2e/tsconfig.json | 1 + e2e/util/asserts.ts | 2 +- e2e/util/query.ts | 18 +++ .../block-scroll-strategy-e2e.css | 29 ++++ .../block-scroll-strategy-e2e.html | 10 ++ .../block-scroll-strategy-e2e.ts | 13 ++ src/e2e-app/e2e-app-module.ts | 4 +- src/e2e-app/e2e-app/e2e-app.html | 1 + src/e2e-app/e2e-app/routes.ts | 2 + src/lib/core/overlay/index.ts | 1 + 11 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 e2e/components/block-scroll-strategy/block-scroll-strategy.e2e.ts create mode 100644 src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.css create mode 100644 src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.html create mode 100644 src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.ts diff --git a/e2e/components/block-scroll-strategy/block-scroll-strategy.e2e.ts b/e2e/components/block-scroll-strategy/block-scroll-strategy.e2e.ts new file mode 100644 index 000000000000..eaaf2d0359a9 --- /dev/null +++ b/e2e/components/block-scroll-strategy/block-scroll-strategy.e2e.ts @@ -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});`); +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 53ea134f0699..f4690fca96eb 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -6,6 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "inlineSources": true, + "lib": ["es2015"], "module": "commonjs", "moduleResolution": "node", "noEmitOnError": true, diff --git a/e2e/util/asserts.ts b/e2e/util/asserts.ts index dc624c00d579..f507d096e399 100644 --- a/e2e/util/asserts.ts +++ b/e2e/util/asserts.ts @@ -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) => { diff --git a/e2e/util/query.ts b/e2e/util/query.ts index 03f6b061920b..61186685f721 100644 --- a/e2e/util/query.ts +++ b/e2e/util/query.ts @@ -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 { + 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(snippet); +} + export type FinderResult = ElementFinder | string; diff --git a/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.css b/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.css new file mode 100644 index 000000000000..d43c1c92ae17 --- /dev/null +++ b/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.css @@ -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; +} diff --git a/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.html b/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.html new file mode 100644 index 000000000000..294c7f163571 --- /dev/null +++ b/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.html @@ -0,0 +1,10 @@ +

+ + +

+
+ +
+
+
+
diff --git a/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.ts b/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.ts new file mode 100644 index 000000000000..a9e8f4b7e3e3 --- /dev/null +++ b/src/e2e-app/block-scroll-strategy/block-scroll-strategy-e2e.ts @@ -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); +} diff --git a/src/e2e-app/e2e-app-module.ts b/src/e2e-app/e2e-app-module.ts index 463066b0ec9e..6bcbc4852092 100644 --- a/src/e2e-app/e2e-app-module.ts +++ b/src/e2e-app/e2e-app-module.ts @@ -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: [ diff --git a/src/e2e-app/e2e-app/e2e-app.html b/src/e2e-app/e2e-app/e2e-app.html index fba9328e0934..d5357b1dba2a 100644 --- a/src/e2e-app/e2e-app/e2e-app.html +++ b/src/e2e-app/e2e-app/e2e-app.html @@ -1,6 +1,7 @@ + Block scroll strategy Button Checkbox Dialog diff --git a/src/e2e-app/e2e-app/routes.ts b/src/e2e-app/e2e-app/routes.ts index 1037d6089dfd..f2c986d8a924 100644 --- a/src/e2e-app/e2e-app/routes.ts +++ b/src/e2e-app/e2e-app/routes.ts @@ -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}, diff --git a/src/lib/core/overlay/index.ts b/src/lib/core/overlay/index.ts index 98feccf0c421..7522a9334951 100644 --- a/src/lib/core/overlay/index.ts +++ b/src/lib/core/overlay/index.ts @@ -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'; From 0d65b8135294b8ca9eb230f47d7c4bdc1bd46d46 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Mon, 15 May 2017 18:52:54 +0200 Subject: [PATCH 4/4] chore: move scroll block class --- src/lib/core/_core.scss | 13 ------------- src/lib/core/overlay/_overlay.scss | 13 +++++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/lib/core/_core.scss b/src/lib/core/_core.scss index 4d6f5b10f39c..2eeb5405d386 100644 --- a/src/lib/core/_core.scss +++ b/src/lib/core/_core.scss @@ -41,17 +41,4 @@ .mat-theme-loaded-marker { display: 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; - } } diff --git a/src/lib/core/overlay/_overlay.scss b/src/lib/core/overlay/_overlay.scss index ce6a99b5411a..032376df0e8d 100644 --- a/src/lib/core/overlay/_overlay.scss +++ b/src/lib/core/overlay/_overlay.scss @@ -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; + } }