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/_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; + } } diff --git a/src/lib/core/overlay/index.ts b/src/lib/core/overlay/index.ts index 07fee5e728df..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'; @@ -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'; 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..742ab31ab4d7 --- /dev/null +++ b/src/lib/core/overlay/scroll/block-scroll-strategy.spec.ts @@ -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 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 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(); + } + +}); 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..438db2efb560 --- /dev/null +++ b/src/lib/core/overlay/scroll/block-scroll-strategy.ts @@ -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; + } +} 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; }