Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid scroll bleed when displaying modal UI on mobile #4398

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 4 additions & 4 deletions components/popover/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion components/popover/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -321,7 +322,10 @@ class Popover extends Component {
content = <Fill name={ SLOT_NAME }>{ content }</Fill>;
}

return <span ref={ this.bindNode( 'anchor' ) }>{ content }</span>;
return <span ref={ this.bindNode( 'anchor' ) }>
{ content }
{ this.state.isMobile && expandOnMobile && <ScrollLock /> }
</span>;
}
}

Expand Down
21 changes: 21 additions & 0 deletions components/scroll-lock/README.md
Original file line number Diff line number Diff line change
@@ -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 (
<div>
Sidebar Content!
{ isMobile && <ScrollLock /> }
</div>
);
}
```
114 changes: 114 additions & 0 deletions components/scroll-lock/index.js
Original file line number Diff line number Diff line change
@@ -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();
4 changes: 4 additions & 0 deletions components/scroll-lock/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
html.lockscroll,
body.lockscroll {
overflow: hidden;
}
53 changes: 53 additions & 0 deletions components/scroll-lock/test/index.js
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no risk, all test suites are isolated when you use Jest :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was very excited by this, but in my testing, it seems like the global document persists between tests and suites.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I will check it once in master, out of curiosity :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a class to the body in one test, checked for its presence in beforeEach, and found the class on the body in the second test. Are you up for letting me know if you find different behavior? I'd love to take advantage of isolated environment.

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( <ScrollLock /> );
expectLocked( true );
} );
it( 'unlocks when unmounted', () => {
wrapper = mount( <ScrollLock /> );
expectLocked( true );
wrapper.unmount();
expectLocked( false );
} );
} );
14 changes: 11 additions & 3 deletions edit-post/components/layout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
} );

Expand Down Expand Up @@ -84,6 +88,9 @@ function Layout( {
) }
{ editorSidebarOpened && <Sidebar /> }
{ pluginSidebarOpened && <PluginSidebar.Slot name={ sidebarName } /> }
{
isMobileViewport && sidebarIsOpened && <ScrollLock />
}
<Popover.Slot />
<PluginArea />
</div>
Expand All @@ -105,5 +112,6 @@ export default compose(
withDispatch( ( dispatch ) => ( {
closePublishSidebar: dispatch( 'core/edit-post' ).closePublishSidebar,
} ) ),
navigateRegions
navigateRegions,
withViewportMatch( { isMobileViewport: '< small' } ),
)( Layout );