Skip to content

Instantly share code, notes, and snippets.

@valscion
Created January 8, 2017 15:39
Show Gist options
  • Select an option

  • Save valscion/44291307fee698dce8059da939d39321 to your computer and use it in GitHub Desktop.

Select an option

Save valscion/44291307fee698dce8059da939d39321 to your computer and use it in GitHub Desktop.
Preventing scroll for modals
/* @flow */
import React, { Component } from 'react';
// $FlowFixMe
import $ from 'jquery';
// Disables body element scrolling on touch past the element passed in.
// This is the only way to stop the body element from scrolling on Mobile Safari
function preventOverscroll(el: HTMLElement) {
// If the element is not scrollable, the body may scroll instead.
// We need to make sure that there is always room to scroll the element: upwards, if we are at the
// top, or downwards if we are at the bottom.
const ensureElementWillBeScrolledIfScrollable = () => {
const totalScrollableHeight = el.scrollHeight;
const visibleElementHeight = el.offsetHeight;
const isElementScrollable = visibleElementHeight < totalScrollableHeight;
// Changing el.scrollTop only works when the element is scrollable
if (isElementScrollable) {
const elementScrollPositionTop = el.scrollTop;
const elementScrollPositionBottom = elementScrollPositionTop + visibleElementHeight;
const currentlyScrolledToTop = elementScrollPositionTop === 0;
const currentlyScrolledToBottom = elementScrollPositionBottom === totalScrollableHeight;
// Scroll one pixel down if we are at the top, and one pixel up for bottom
if (currentlyScrolledToTop) {
el.scrollTop += 1;
} else if (currentlyScrolledToBottom) {
el.scrollTop -= 1;
}
}
};
const preventScrolling = (evt: TouchEvent) => {
// Prevent all the scrolling that has bubbled all the way to document.body
evt.preventDefault();
};
const preventScrollPreventionOnElementIfScrollable = (evt: TouchEvent) => {
const visibleElementHeight = el.offsetHeight;
const totalScrollableHeight = el.scrollHeight;
// If the element is long enough, it can handle its own scroll
const isElementScrollable = visibleElementHeight < totalScrollableHeight;
if (isElementScrollable) {
// In this case we stop propagation to prevent the scroll prevention from being called
evt.stopPropagation();
}
};
el.addEventListener('touchstart', ensureElementWillBeScrolledIfScrollable);
el.addEventListener('touchmove', preventScrollPreventionOnElementIfScrollable);
// Prevent all scrolling that reaches document.body. Note that this will NOT fire if the touch
// originated behind the Mobile Safari bottom browser area. This is a known tradeoff:
// https://github.com/venuu/venuu/pull/2293#issuecomment-270137705
document.body.addEventListener('touchmove', preventScrolling);
return () => {
el.removeEventListener('touchstart', ensureElementWillBeScrolledIfScrollable);
el.removeEventListener('touchmove', preventScrollPreventionOnElementIfScrollable);
document.body.removeEventListener('touchmove', preventScrolling);
};
}
export default class BodyScrollPreventer extends Component {
props: {
getScrollLimitElement?: () => ?HTMLElement,
children?: React$Element<*>
};
disableOverscrollPrevention: ?(() => void); // eslint-disable-line react/sort-comp
componentDidMount() {
const { getScrollLimitElement } = this.props;
const scrollLimitElement = getScrollLimitElement && getScrollLimitElement();
if (scrollLimitElement) {
this.disableOverscrollPrevention = preventOverscroll(scrollLimitElement);
}
// This class only adds `overflow: hidden;` CSS
$('body').addClass('l-prevent-scroll');
}
componentWillUnmount() {
if (this.disableOverscrollPrevention) {
this.disableOverscrollPrevention();
}
$('body').removeClass('l-prevent-scroll');
}
render() {
return <div>{ this.props.children }</div>;
}
}
/* @flow */
import React from 'react';
// $FlowFixMe
import ReactModal2 from 'react-modal2';
// $FlowFixMe
import Portal from 'react-portal';
import { findDOMNode } from 'react-dom';
import BodyScrollPreventer from './BodyScrollPreventer';
import styles from './Modal.scss';
type Props = {|
onClose: () => void,
children?: React$Element<*>
|};
type RefValue = null | React$Component<any, any, any> | HTMLElement;
// Allow ReactModal2 to hide the "application" for a11y reasons when modals are opened.
// Modals are always opened outside the element targeted by this following function.
// See: https://github.com/cloudflare/react-modal2#accessibility
ReactModal2.getApplicationElement = () => document.getElementById('a11y-application-container');
export default class Modal extends React.Component {
props: Props;
modalScrollContainerElement: ?HTMLElement; // eslint-disable-line react/sort-comp
state = {
renderModal: false
};
componentDidMount() {
// Work around Mobile Safari scrolling the body to the top of the page when modal mounts.
// By delaying the modal rendering by two ticks, we allow Safari to recover the viewport
// height after a form field has been blurred, and to not screw up the scroll position
// when a backdrop with `position: fixed` style is added to the DOM.
//
// Two ticks are needed, as hiding keyboard seems to take one tick and hiding the bottom
// takes another tick. I'm not very confident on this, though...
//
// See https://github.com/venuu/venuu/pull/2293#issuecomment-270116195
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.setState({ renderModal: true });
});
});
}
render() {
// react-portal requires a child to be present to work, so render an empty <div>
// if we're not yet ready to render the modal itself
return (
<Portal isOpened>
{this.state.renderModal ? this._renderModal() : <div />}
</Portal>
);
}
_renderModal() {
const props = this.props;
return (
<BodyScrollPreventer getScrollLimitElement={() => this.modalScrollContainerElement}>
<ReactModal2
onClose={props.onClose}
closeOnEsc
backdropClassName={styles.fullPageBackground}
modalClassName={styles.modalScrollContainer}
ref={this._storeModalScrollContainerElement}
>
<div>
<div
className={styles.fakeFullHeightBackground}
onClick={props.onClose}
/>
<div className={styles.content}>
{ props.children }
<button type='button' className={styles.close} onClick={props.onClose} >
&times;
</button>
</div>
</div>
</ReactModal2>
</BodyScrollPreventer>
);
}
_storeModalScrollContainerElement = (reactModalElement: RefValue) => {
if (!reactModalElement) {
this.modalScrollContainerElement = null;
} else {
const htmlElement: HTMLElement = findDOMNode(reactModalElement);
// The ref node received from react-modal2 is the backdrop element. We need to get access
// to the only child inside the modal, which will be the one to contain the scroll
this.modalScrollContainerElement = htmlElement.children[0];
}
}
}
/**
* Styles for modals. They consist of four major UI layout parts, because mobile scrolling with
* modals is a bit tricky to get right. The parts are:
*
* .fullPageBackground
* .modalScrollContainer
* .fakeFullHeightBackground
* .content
* <the content is here>
*
* The structure is this way so that the .fakeFullHeightBackground can stay still and cover the
* entire screen when .modalScrollContainer is rendered. The content that actually scrolls inside
* the modal resides under .content, so that the offsetHeight of the scrolling element
* `.modalScrollContainer` will always be 100% and the scrolling logic will work properly.
*/
@import '~stylesheets/foundation_settings_js';
@import '~foundation/components/global';
// The highest z-index you know you must win over
$base-z-index: 100000;
// The background is used to set the main position context and the page-covering background-color
// for every scenario
.fullPageBackground {
background-color: #000000;
background-color: rgba(0, 0, 0, 0.8);
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: 0;
z-index: $base-z-index + 1;
}
// The modalContainer is the element that covers the entire page and acts only as the scrollable
// element container. This _can't_ have any layout affecting styles (such as `border`), or the
// body scroll prevention logic will fail
.modalScrollContainer {
display: block;
position: absolute;
width: 100%;
height: 100%;
z-index: $base-z-index + 2;
left: 0;
right: 0;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
// This element will have a tabindex="-1" attribute, so it can receive focus programmatically.
// We don't need to show that outline visually, though, as the focus is intended only for screen
// readers to understand we're in a new scope now.
outline: none;
// The tabindex might cause an ugly tap highlight color on Mobile Safari, so disable it
-webkit-tap-highlight-color: rgba(0,0,0,0);
}
// This is an element that is a sibling of the actual content that is used purely to get a
// background set for mobile "overflow" situation, where the content is too short to cover
// the entire page
.fakeFullHeightBackground {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 0;
background-color: #FFFFFF;
margin-left: auto;
margin-right: auto;
// On small screens with the background-color fill, we don't want to receive click events
// on this element as it would close the modal
pointer-events: none;
// This $medium-up is a Foundation media query to target devices >768px
@media #{$medium-up} {
background-color: transparent;
// Now that this element is actually transparent, we can enable clicking on this again to
// allow the modal to close when the background is clicked
pointer-events: all;
}
}
// Finally, the content itself. This wrapper can't have any `height` definitions as this needs to
// grow and shrink according to the content height.
//
// Larger screen specific styling and offsetting can happen only here to keep the scrolling
// element still covering the entire page
.content {
// Relative positioning is needed for the close button to get inside this element
position: relative;
padding: rem-calc(30); // rem-calc() converts px values to `rem` units
background-color: #FFFFFF;
@media #{$medium-up} {
max-width: 2/3 * $row-width; // Equal to 8 columns on a 12-column layout
width: 80%;
margin-top: rem-calc(50);
margin-bottom: rem-calc(50);
margin-left: auto;
margin-right: auto;
border: 1px solid #666666;
border-radius: $global-radius;
}
}
$close-button-size: rem-calc(40);
.close {
position: absolute;
width: $close-button-size;
height: $close-button-size;
top: rem-calc(8);
right: rem-calc(8);
margin: 0;
padding: 0;
font-size: $close-button-size;
line-height: $close-button-size;
font-weight: lighter;
background-color: transparent;
color: $primary-color; // Same color as for links
cursor: pointer;
&:hover, &:focus {
color: $primary-color-effect; // A bit darker color
background-color: transparent; // override Foundation default
}
&:active {
box-shadow: none;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment