From 044a5561cf68356e96048143c04240607d4b4705 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 10 Jan 2018 16:53:02 -0700 Subject: [PATCH] Lock body scroll for modal UI on mobile --- components/index.js | 1 + components/popover/README.md | 8 +- components/popover/index.js | 6 +- components/scroll-lock/README.md | 21 +++++ components/scroll-lock/index.js | 114 +++++++++++++++++++++++++++ components/scroll-lock/index.scss | 4 + components/scroll-lock/test/index.js | 53 +++++++++++++ edit-post/components/layout/index.js | 14 +++- 8 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 components/scroll-lock/README.md create mode 100644 components/scroll-lock/index.js create mode 100644 components/scroll-lock/index.scss create mode 100644 components/scroll-lock/test/index.js diff --git a/components/index.js b/components/index.js index f720220639972..a108aa40d4fd2 100644 --- a/components/index.js +++ b/components/index.js @@ -23,6 +23,7 @@ export { default as KeyboardShortcuts } from './keyboard-shortcuts'; export { default as MenuItemsChoice } from './menu-items/menu-items-choice'; export { default as MenuItemsGroup } from './menu-items/menu-items-group'; export { default as MenuItemsToggle } from './menu-items/menu-items-toggle'; +export { default as ScrollLock } from './scroll-lock'; export { NavigableMenu, TabbableContainer } from './navigable-container'; export { default as Notice } from './notice'; export { default as NoticeList } from './notice/list'; diff --git a/components/popover/README.md b/components/popover/README.md index 54577251f30bb..abf1bc80c3c66 100644 --- a/components/popover/README.md +++ b/components/popover/README.md @@ -29,7 +29,7 @@ function ToggleButton( { isVisible, toggleVisible } ) { If a Popover is returned by your component, it will be shown. To hide the popover, simply omit it from your component's render value. -If you want Popover elementss to render to a specific location on the page to allow style cascade to take effect, you must render a `Popover.Slot` further up the element tree: +If you want Popover elements to render to a specific location on the page to allow style cascade to take effect, you must render a `Popover.Slot` further up the element tree: ```jsx import { render } from '@wordpress/element'; @@ -81,21 +81,21 @@ An optional additional class name to apply to the rendered popover. - Type: `String` - Required: No -## onClose +### onClose A callback invoked when the popover should be closed. - Type: `Function` - Required: No -## onClickOutside +### onClickOutside A callback invoked when the user clicks outside the opened popover, passing the click event. The popover should be closed in response to this interaction. Defaults to `onClose`. - Type: `Function` - Required: No -## expandOnMobile +### expandOnMobile Opt-in prop to show popovers fullscreen on mobile, pass `false` in this prop to avoid this behavior. diff --git a/components/popover/index.js b/components/popover/index.js index df975fd536975..bc542840efae9 100644 --- a/components/popover/index.js +++ b/components/popover/index.js @@ -17,6 +17,7 @@ import './style.scss'; import withFocusReturn from '../higher-order/with-focus-return'; import PopoverDetectOutside from './detect-outside'; import IconButton from '../icon-button'; +import ScrollLock from '../scroll-lock'; import { Slot, Fill } from '../slot-fill'; /** @@ -321,7 +322,10 @@ class Popover extends Component { content = { content }; } - return { content }; + return + { content } + { this.state.isMobile && expandOnMobile && } + ; } } diff --git a/components/scroll-lock/README.md b/components/scroll-lock/README.md new file mode 100644 index 0000000000000..d74373e9d2e92 --- /dev/null +++ b/components/scroll-lock/README.md @@ -0,0 +1,21 @@ +ScrollLock +========== + +ScrollLock is a content-free React component for declaratively preventing scroll bleed from modal UI to the page body. This component applies a `lockscroll` class to the `document.documentElement` and `document.scrollingElement` elements to stop the body from scrolling. When it is present, the lock is applied. + +## Usage + +Declare scroll locking as part of modal UI. + +```jsx +import { ScrollLock } from '@wordpress/components'; + +function Sidebar( { isMobile } ) { + return ( +
+ Sidebar Content! + { isMobile && } +
+ ); +} +``` diff --git a/components/scroll-lock/index.js b/components/scroll-lock/index.js new file mode 100644 index 0000000000000..aaea8ce84f419 --- /dev/null +++ b/components/scroll-lock/index.js @@ -0,0 +1,114 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import './index.scss'; + +/** + * Creates a ScrollLock component bound to the specified document. + * + * This function creates a ScrollLock component for the specified document + * and is exposed so we can create an isolated component for unit testing. + * + * @param {Object} args Keyword args. + * @param {HTMLDocument} args.htmlDocument The document to lock the scroll for. + * @param {string} args.className The name of the class used to lock scrolling. + * @return {Component} The bound ScrollLock component. + */ +export function createScrollLockComponent( { + htmlDocument = document, + className = 'lockscroll', +} = {} ) { + let lockCounter = 0; + + /* + * Setting `overflow: hidden` on html and body elements resets body scroll in iOS. + * Save scroll top so we can restore it after locking scroll. + * + * NOTE: It would be cleaner and possibly safer to find a localized solution such + * as preventing default on certain touchmove events. + */ + let previousScrollTop = 0; + + /** + * Locks and unlocks scroll depending on the boolean argument. + * + * @param {boolean} locked Whether or not scroll should be locked. + */ + function setLocked( locked ) { + const scrollingElement = htmlDocument.scrollingElement || htmlDocument.body; + + if ( locked ) { + previousScrollTop = scrollingElement.scrollTop; + } + + const methodName = locked ? 'add' : 'remove'; + scrollingElement.classList[ methodName ]( className ); + + // Adding the class to the document element seems to be necessary in iOS. + htmlDocument.documentElement.classList[ methodName ]( className ); + + if ( ! locked ) { + scrollingElement.scrollTop = previousScrollTop; + } + } + + /** + * Requests scroll lock. + * + * This function tracks requests for scroll lock. It locks scroll on the first + * request and counts each request so `releaseLock` can unlock scroll when + * all requests have been released. + */ + function requestLock() { + if ( lockCounter === 0 ) { + setLocked( true ); + } + + ++lockCounter; + } + + /** + * Releases a request for scroll lock. + * + * This function tracks released requests for scroll lock. When all requests + * have been released, it unlocks scroll. + */ + function releaseLock() { + if ( lockCounter === 1 ) { + setLocked( false ); + } + + --lockCounter; + } + + return class ScrollLock extends Component { + /** + * Requests scroll lock on mount. + */ + componentDidMount() { + requestLock(); + } + /** + * Releases scroll lock before unmount. + */ + componentWillUnmount() { + releaseLock(); + } + + /** + * Render nothing as this component is merely a way to declare scroll lock. + * + * @return {null} Render nothing by returning `null`. + */ + render() { + return null; + } + }; +} + +export default createScrollLockComponent(); diff --git a/components/scroll-lock/index.scss b/components/scroll-lock/index.scss new file mode 100644 index 0000000000000..e1c8cebda053f --- /dev/null +++ b/components/scroll-lock/index.scss @@ -0,0 +1,4 @@ +html.lockscroll, +body.lockscroll { + overflow: hidden; +} diff --git a/components/scroll-lock/test/index.js b/components/scroll-lock/test/index.js new file mode 100644 index 0000000000000..c86daf178cb7b --- /dev/null +++ b/components/scroll-lock/test/index.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import { createScrollLockComponent } from '..'; + +describe( 'scroll-lock', () => { + const lockingClassName = 'test-lock-scroll'; + + // Use a separate document to reduce the risk of test side-effects. + let testDocument = null; + let ScrollLock = null; + let wrapper = null; + + function expectLocked( locked ) { + expect( testDocument.documentElement.classList.contains( lockingClassName ) ).toBe( locked ); + // Assert against `body` because `scrollingElement` does not exist on our test DOM implementation. + expect( testDocument.body.classList.contains( lockingClassName ) ).toBe( locked ); + } + + beforeEach( () => { + testDocument = document.implementation.createHTMLDocument( 'Test scroll-lock' ); + ScrollLock = createScrollLockComponent( { + htmlDocument: testDocument, + className: lockingClassName, + } ); + } ); + + afterEach( () => { + testDocument = null; + + if ( wrapper ) { + wrapper.unmount(); + wrapper = null; + } + } ); + + it( 'locks when mounted', () => { + expectLocked( false ); + wrapper = mount( ); + expectLocked( true ); + } ); + it( 'unlocks when unmounted', () => { + wrapper = mount( ); + expectLocked( true ); + wrapper.unmount(); + expectLocked( false ); + } ); +} ); diff --git a/edit-post/components/layout/index.js b/edit-post/components/layout/index.js index 30de1adb50b94..bfbb993447816 100644 --- a/edit-post/components/layout/index.js +++ b/edit-post/components/layout/index.js @@ -7,7 +7,7 @@ import { some } from 'lodash'; /** * WordPress dependencies */ -import { Popover, navigateRegions } from '@wordpress/components'; +import { Popover, ScrollLock, navigateRegions } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { AutosaveMonitor, @@ -20,6 +20,7 @@ import { import { withDispatch, withSelect } from '@wordpress/data'; import { compose } from '@wordpress/element'; import { PluginArea } from '@wordpress/plugins'; +import { withViewportMatch } from '@wordpress/viewport'; /** * Internal dependencies @@ -45,9 +46,12 @@ function Layout( { metaBoxes, hasActiveMetaboxes, isSaving, + isMobileViewport, } ) { + const sidebarIsOpened = editorSidebarOpened || pluginSidebarOpened || publishSidebarOpened; + const className = classnames( 'edit-post-layout', { - 'is-sidebar-opened': editorSidebarOpened || pluginSidebarOpened || publishSidebarOpened, + 'is-sidebar-opened': sidebarIsOpened, 'has-fixed-toolbar': hasFixedToolbar, } ); @@ -84,6 +88,9 @@ function Layout( { ) } { editorSidebarOpened && } { pluginSidebarOpened && } + { + isMobileViewport && sidebarIsOpened && + } @@ -105,5 +112,6 @@ export default compose( withDispatch( ( dispatch ) => ( { closePublishSidebar: dispatch( 'core/edit-post' ).closePublishSidebar, } ) ), - navigateRegions + navigateRegions, + withViewportMatch( { isMobileViewport: '< small' } ), )( Layout );