// @flow import { useCallback, useEffect, useRef } from 'react'; const STYLE_KEYS = [ 'boxSizing', 'height', 'overflow', 'paddingRight', 'position', ]; const LOCK_STYLES = { boxSizing: 'border-box', // account for possible declaration `width: 100%;` on body overflow: 'hidden', position: 'relative', height: '100%', }; function preventTouchMove(e: TouchEvent) { e.preventDefault(); } function allowTouchMove(e: TouchEvent) { e.stopPropagation(); } function preventInertiaScroll() { const top = this.scrollTop; const totalScroll = this.scrollHeight; const currentScroll = top + this.offsetHeight; if (top === 0) { this.scrollTop = 1; } else if (currentScroll === totalScroll) { this.scrollTop = top - 1; } } // `ontouchstart` check works on most browsers // `maxTouchPoints` works on IE10/11 and Surface function isTouchDevice() { return 'ontouchstart' in window || navigator.maxTouchPoints; } const canUseDOM = !!( typeof window !== 'undefined' && window.document && window.document.createElement ); let activeScrollLocks = 0; type Options = { isEnabled: boolean, accountForScrollbars?: boolean, }; type TargetStyle = { [key: string]: string | null, }; const listenerOptions = { capture: false, passive: false, }; export default function useScrollLock({ isEnabled, accountForScrollbars = true, }: Options) { const originalStyles = useRef({}); const scrollTarget = useRef(null); const addScrollLock = useCallback((touchScrollTarget: HTMLElement | null) => { if (!canUseDOM) return; const target = document.body; const targetStyle = target && (target.style: TargetStyle); if (accountForScrollbars) { // store any styles already applied to the body STYLE_KEYS.forEach(key => { const val = targetStyle && targetStyle[key]; originalStyles.current[key] = val; }); } // apply the lock styles and padding if this is the first scroll lock if (accountForScrollbars && activeScrollLocks < 1) { const currentPadding = parseInt(originalStyles.current.paddingRight, 10) || 0; const clientWidth = document.body ? document.body.clientWidth : 0; const adjustedPadding = window.innerWidth - clientWidth + currentPadding || 0; Object.keys(LOCK_STYLES).forEach(key => { const val = LOCK_STYLES[key]; if (targetStyle) { targetStyle[key] = val; } }); if (targetStyle) { targetStyle.paddingRight = `${adjustedPadding}px`; } } // account for touch devices if (target && isTouchDevice()) { // Mobile Safari ignores { overflow: hidden } declaration on the body. target.addEventListener('touchmove', preventTouchMove, listenerOptions); // Allow scroll on provided target if (touchScrollTarget) { touchScrollTarget.addEventListener( 'touchstart', preventInertiaScroll, listenerOptions ); touchScrollTarget.addEventListener( 'touchmove', allowTouchMove, listenerOptions ); } } // increment active scroll locks activeScrollLocks += 1; }, []); const removeScrollLock = useCallback( (touchScrollTarget: HTMLElement | null) => { if (!canUseDOM) return; const target = document.body; const targetStyle = target && (target.style: TargetStyle); // safely decrement active scroll locks activeScrollLocks = Math.max(activeScrollLocks - 1, 0); // reapply original body styles, if any if (accountForScrollbars && activeScrollLocks < 1) { STYLE_KEYS.forEach(key => { const val = originalStyles.current[key]; if (targetStyle) { targetStyle[key] = val; } }); } // remove touch listeners if (target && isTouchDevice()) { target.removeEventListener( 'touchmove', preventTouchMove, listenerOptions ); if (touchScrollTarget) { touchScrollTarget.removeEventListener( 'touchstart', preventInertiaScroll, listenerOptions ); touchScrollTarget.removeEventListener( 'touchmove', allowTouchMove, listenerOptions ); } } }, [] ); useEffect(() => { if (!isEnabled) return; const element = scrollTarget.current; addScrollLock(element); return () => { removeScrollLock(element); }; }, [isEnabled, addScrollLock, removeScrollLock]); return (element: HTMLElement | null) => { scrollTarget.current = element; }; }