diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 52e670be9632..8f0c3779f5c1 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -47,6 +47,7 @@ export {OverlayContainer} from './overlay/overlay-container'; export {FullscreenOverlayContainer} from './overlay/fullscreen-overlay-container'; export {OverlayRef} from './overlay/overlay-ref'; export {OverlayState} from './overlay/overlay-state'; +export {DisableBodyScroll} from './overlay/disable-body-scroll'; export { ConnectedOverlayDirective, OverlayOrigin, diff --git a/src/lib/core/overlay/disable-body-scroll.spec.ts b/src/lib/core/overlay/disable-body-scroll.spec.ts new file mode 100644 index 000000000000..d76b6fe13596 --- /dev/null +++ b/src/lib/core/overlay/disable-body-scroll.spec.ts @@ -0,0 +1,144 @@ +import {TestBed, async, inject} from '@angular/core/testing'; +import {OverlayModule} from './overlay-directives'; +import {DisableBodyScroll} from './disable-body-scroll'; + + +describe('DisableBodyScroll', () => { + let service: DisableBodyScroll; + let startingWindowHeight = window.innerHeight; + let forceScrollElement: HTMLElement = document.createElement('div'); + + forceScrollElement.style.height = '3000px'; + forceScrollElement.style.width = '100px'; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [OverlayModule.forRoot()], + providers: [DisableBodyScroll] + }); + })); + + beforeEach(inject([DisableBodyScroll], (disableBodyScroll: DisableBodyScroll) => { + service = disableBodyScroll; + })); + + afterEach(() => { + if (forceScrollElement.parentNode) { + forceScrollElement.parentNode.removeChild(forceScrollElement); + } + + service.deactivate(); + }); + + it('should prevent scrolling', () => { + document.body.appendChild(forceScrollElement); + window.scrollTo(0, 100); + + // In the iOS simulator (BrowserStack & SauceLabs), adding the content to the + // body causes karma's iframe for the test to stretch to fit that content once we attempt to + // scroll the page. Setting width / height / maxWidth / maxHeight on the iframe does not + // successfully constrain its size. As such, skip assertions in environments where the + // window size has changed since the start of the test. + if (window.innerHeight > startingWindowHeight) { + return; + } + + window.scrollTo(0, 100); + + service.activate(); + + window.scrollTo(0, 500); + + expect(window.pageYOffset).toBe(0); + }); + + it('should toggle the isActive property', () => { + document.body.appendChild(forceScrollElement); + window.scrollTo(0, 100); + + // In the iOS simulator (BrowserStack & SauceLabs), adding the content to the + // body causes karma's iframe for the test to stretch to fit that content once we attempt to + // scroll the page. Setting width / height / maxWidth / maxHeight on the iframe does not + // successfully constrain its size. As such, skip assertions in environments where the + // window size has changed since the start of the test. + if (window.innerHeight > startingWindowHeight) { + return; + } + + service.activate(); + expect(service.isActive).toBe(true); + + service.deactivate(); + expect(service.isActive).toBe(false); + }); + + it('should not disable scrolling if the content is shorter than the viewport height', () => { + service.activate(); + expect(service.isActive).toBe(false); + }); + + it('should add the proper inline styles to the and nodes', () => { + document.body.appendChild(forceScrollElement); + window.scrollTo(0, 500); + + // In the iOS simulator (BrowserStack & SauceLabs), adding the content to the + // body causes karma's iframe for the test to stretch to fit that content once we attempt to + // scroll the page. Setting width / height / maxWidth / maxHeight on the iframe does not + // successfully constrain its size. As such, skip assertions in environments where the + // window size has changed since the start of the test. + if (window.innerHeight > startingWindowHeight) { + return; + } + + let bodyCSS = document.body.style; + let htmlCSS = document.documentElement.style; + + service.activate(); + + expect(bodyCSS.position).toBe('fixed'); + expect(bodyCSS.width).toBe('100%'); + expect(bodyCSS.top).toBe('-500px'); + expect(bodyCSS.maxWidth).toBeTruthy(); + expect(htmlCSS.overflowY).toBe('scroll'); + }); + + it('should revert any previously-set inline styles', () => { + let bodyCSS = document.body.style; + let htmlCSS = document.documentElement.style; + + document.body.appendChild(forceScrollElement); + + bodyCSS.position = 'static'; + bodyCSS.width = '1000px'; + htmlCSS.overflowY = 'hidden'; + + service.activate(); + service.deactivate(); + + expect(bodyCSS.position).toBe('static'); + expect(bodyCSS.width).toBe('1000px'); + expect(htmlCSS.overflowY).toBe('hidden'); + + bodyCSS.cssText = ''; + htmlCSS.cssText = ''; + }); + + it('should restore the scroll position when enabling scrolling', () => { + document.body.appendChild(forceScrollElement); + window.scrollTo(0, 1000); + + // In the iOS simulator (BrowserStack & SauceLabs), adding the content to the + // body causes karma's iframe for the test to stretch to fit that content once we attempt to + // scroll the page. Setting width / height / maxWidth / maxHeight on the iframe does not + // successfully constrain its size. As such, skip assertions in environments where the + // window size has changed since the start of the test. + if (window.innerHeight > startingWindowHeight) { + return; + } + + service.activate(); + service.deactivate(); + + expect(window.pageYOffset).toBe(1000); + }); +}); diff --git a/src/lib/core/overlay/disable-body-scroll.ts b/src/lib/core/overlay/disable-body-scroll.ts new file mode 100644 index 000000000000..1ffc40a48778 --- /dev/null +++ b/src/lib/core/overlay/disable-body-scroll.ts @@ -0,0 +1,74 @@ +import {Injectable, Optional, SkipSelf} from '@angular/core'; +import {ViewportRuler} from './position/viewport-ruler'; + + +/** + * Utilitity that allows for toggling scrolling of the viewport on/off. + */ +@Injectable() +export class DisableBodyScroll { + private _bodyStyles: string = ''; + private _htmlStyles: string = ''; + private _previousScrollPosition: number = 0; + private _isActive: boolean = false; + + /** Whether scrolling is disabled. */ + public get isActive(): boolean { + return this._isActive; + } + + constructor(private _viewportRuler: ViewportRuler) { } + + /** + * Disables scrolling if it hasn't been disabled already and if the body is scrollable. + */ + activate(): void { + let body = document.body; + let bodyHeight = body.scrollHeight; + let viewportHeight = this._viewportRuler.getViewportRect().height; + + if (!this.isActive && bodyHeight > viewportHeight) { + let html = document.documentElement; + let initialBodyWidth = body.clientWidth; + + this._htmlStyles = html.style.cssText || ''; + this._bodyStyles = body.style.cssText || ''; + this._previousScrollPosition = this._viewportRuler.getViewportScrollPosition().top; + + body.style.position = 'fixed'; + body.style.width = '100%'; + body.style.top = -this._previousScrollPosition + 'px'; + html.style.overflowY = 'scroll'; + + // TODO(crisbeto): this avoids issues if the body has a margin, however it prevents the + // body from adapting if the window is resized. check whether it's ok to reset the body + // margin in the core styles. + body.style.maxWidth = initialBodyWidth + 'px'; + + this._isActive = true; + } + } + + /** + * Re-enables scrolling. + */ + deactivate(): void { + if (this.isActive) { + document.body.style.cssText = this._bodyStyles; + document.documentElement.style.cssText = this._htmlStyles; + window.scroll(0, this._previousScrollPosition); + this._isActive = false; + } + } +} + +export function DISABLE_BODY_SCROLL_PROVIDER_FACTORY(parentDispatcher: DisableBodyScroll, + viewportRuler: ViewportRuler) { + return parentDispatcher || new DisableBodyScroll(viewportRuler); +}; + +export const DISABLE_BODY_SCROLL_PROVIDER = { + provide: DisableBodyScroll, + deps: [[new Optional(), new SkipSelf(), DisableBodyScroll]], + useFactory: DISABLE_BODY_SCROLL_PROVIDER_FACTORY +}; diff --git a/src/lib/core/overlay/overlay.ts b/src/lib/core/overlay/overlay.ts index 1105a869c52e..42943fe6c3d2 100644 --- a/src/lib/core/overlay/overlay.ts +++ b/src/lib/core/overlay/overlay.ts @@ -13,6 +13,7 @@ import {OverlayPositionBuilder} from './position/overlay-position-builder'; import {VIEWPORT_RULER_PROVIDER} from './position/viewport-ruler'; import {OverlayContainer, OVERLAY_CONTAINER_PROVIDER} from './overlay-container'; import {SCROLL_DISPATCHER_PROVIDER} from './scroll/scroll-dispatcher'; +import {DISABLE_BODY_SCROLL_PROVIDER} from './disable-body-scroll'; /** Next overlay unique ID. */ @@ -96,4 +97,5 @@ export const OVERLAY_PROVIDERS: Provider[] = [ VIEWPORT_RULER_PROVIDER, SCROLL_DISPATCHER_PROVIDER, OVERLAY_CONTAINER_PROVIDER, + DISABLE_BODY_SCROLL_PROVIDER, ];