Created
January 8, 2017 15:39
-
-
Save valscion/44291307fee698dce8059da939d39321 to your computer and use it in GitHub Desktop.
Preventing scroll for modals
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* @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>; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* @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} > | |
| × | |
| </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]; | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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