UNPKG

react-scroll-blocker

Version:

A modern React 18 compatible scroll lock component for preventing body scroll

327 lines (321 loc) 12.8 kB
import { jsx } from 'react/jsx-runtime'; import React, { useRef, useEffect, cloneElement, useState, useCallback } from 'react'; /** * Utility functions for scroll lock functionality */ /** * Gets the current scrollbar width */ function getScrollbarWidth() { var _a; // Create a temporary element to measure scrollbar width const outer = document.createElement('div'); outer.style.visibility = 'hidden'; outer.style.overflow = 'scroll'; outer.style.msOverflowStyle = 'scrollbar'; // needed for WinJS apps document.body.appendChild(outer); const inner = document.createElement('div'); outer.appendChild(inner); const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; // Clean up (_a = outer.parentNode) === null || _a === void 0 ? void 0 : _a.removeChild(outer); return scrollbarWidth; } /** * Checks if the current device is iOS (mobile devices only, not macOS) */ function isIOS() { // More precise iOS detection that excludes macOS return (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream && 'ontouchstart' in window && !/Macintosh/.test(navigator.userAgent)); } /** * Safely prevents the default behavior of an event if it's cancelable */ function preventDefault(event) { if (event.cancelable) { event.preventDefault(); } } /** * Checks if an element is scrollable */ function isScrollable(element) { const { overflow, overflowX, overflowY } = window.getComputedStyle(element); return /auto|scroll/.test(overflow + overflowX + overflowY); } /** * Finds the closest scrollable parent of an element */ function findScrollableParent(element) { if (!element || element === document.body) { return null; } if (isScrollable(element)) { return element; } return findScrollableParent(element.parentElement); } /** * TouchScrollable component that allows an element to remain scrollable * even when ScrollLock is active. This is particularly important for iOS * devices where overflow: hidden on body doesn't prevent touch scrolling. */ const TouchScrollable = ({ children }) => { const elementRef = useRef(null); useEffect(() => { const element = elementRef.current; if (!element) return; let startY = 0; let allowTouchMove = false; const handleTouchStart = (event) => { if (event.touches.length === 1) { startY = event.touches[0].clientY; // Check if the touch target or its parent is scrollable const target = event.target; const scrollableParent = findScrollableParent(target) || target; allowTouchMove = isScrollable(scrollableParent); } }; const handleTouchMove = (event) => { if (event.touches.length !== 1) return; const touch = event.touches[0]; const currentY = touch.clientY; const element = elementRef.current; if (!element || !allowTouchMove) return; const deltaY = currentY - startY; const scrollTop = element.scrollTop; const scrollHeight = element.scrollHeight; const clientHeight = element.clientHeight; // Check if we're at the boundaries of the scrollable element const isAtTop = scrollTop === 0 && deltaY > 0; const isAtBottom = scrollTop + clientHeight >= scrollHeight && deltaY < 0; // Prevent rubber band scrolling at the boundaries if (isAtTop || isAtBottom) { event.preventDefault(); } }; // Add touch event listeners element.addEventListener('touchstart', handleTouchStart, { passive: false }); element.addEventListener('touchmove', handleTouchMove, { passive: false }); return () => { element.removeEventListener('touchstart', handleTouchStart); element.removeEventListener('touchmove', handleTouchMove); }; }, []); // Clone the child element and add our ref and data attribute const child = React.Children.only(children); return cloneElement(child, { ref: elementRef, 'data-scroll-lock-scrollable': true, ...child.props, }); }; /** * Track active ScrollBlocker instances using a Set for better management */ const activeBlockers = new Set(); /** * Generate unique ID for each ScrollBlocker instance */ let instanceCounter = 0; const generateInstanceId = () => `scroll-blocker-${++instanceCounter}`; /** * Reset all ScrollBlocker instances - useful for emergency cleanup */ const resetScrollBlocker = () => { var _a; // Clear all active instances activeBlockers.clear(); // Restore body styles to default const body = document.body; body.style.overflow = ''; body.style.position = ''; body.style.paddingRight = ''; // Clean up global original styles reference delete window.__scrollBlockerOriginalStyles; // Remove all touch event listeners const clonedBody = body.cloneNode(true); (_a = body.parentNode) === null || _a === void 0 ? void 0 : _a.replaceChild(clonedBody, body); // Clean up any data attributes const existingElements = document.querySelectorAll('[data-scroll-lock-scrollable]'); existingElements.forEach((el) => { el.removeAttribute('data-scroll-lock-scrollable'); }); }; /** * ScrollBlocker component that prevents scrolling on the document body. * * Features: * - Prevents body scroll when mounted and active * - Accounts for scrollbar width to prevent layout shift * - Supports nested scroll blocker instances * - Handles iOS touch scrolling issues * - Allows specific child elements to remain scrollable */ const ScrollBlocker = ({ isActive = true, accountForScrollbars = true, children, }) => { const [isBlocking, setIsBlocking] = useState(false); const instanceIdRef = useRef(); const touchMoveListenerRef = useRef(null); // Generate unique instance ID on first render if (!instanceIdRef.current) { instanceIdRef.current = generateInstanceId(); } useEffect(() => { if (!isActive || isBlocking) { return; } const instanceId = instanceIdRef.current; const body = document.body; // Add this instance to active set activeBlockers.add(instanceId); // Apply scroll blocking styles (only if first instance) if (activeBlockers.size === 1) { // Store the ORIGINAL styles globally on first instance only if (!window.__scrollBlockerOriginalStyles) { window.__scrollBlockerOriginalStyles = { overflow: body.style.overflow, position: body.style.position, paddingRight: body.style.paddingRight, }; } const scrollbarWidth = accountForScrollbars ? getScrollbarWidth() : 0; body.style.overflow = 'hidden'; body.style.position = 'relative'; // ← Add this line! if (scrollbarWidth > 0 && accountForScrollbars) { body.style.paddingRight = `${scrollbarWidth}px`; } // iOS Safari touch handling if (isIOS()) { const preventTouchMove = (event) => { const target = event.target; if (!target.closest('[data-scroll-lock-scrollable]')) { if (event.cancelable && event.type === 'touchmove') { preventDefault(event); } } }; touchMoveListenerRef.current = preventTouchMove; document.addEventListener('touchmove', preventTouchMove, { passive: false, }); } } setIsBlocking(true); // Cleanup function - component-scoped return () => { activeBlockers.delete(instanceId); setIsBlocking(false); // Only restore styles when no more active blockers if (activeBlockers.size === 0) { const originalStyles = window.__scrollBlockerOriginalStyles; body.style.overflow = (originalStyles === null || originalStyles === void 0 ? void 0 : originalStyles.overflow) || ''; body.style.position = (originalStyles === null || originalStyles === void 0 ? void 0 : originalStyles.position) || ''; body.style.paddingRight = (originalStyles === null || originalStyles === void 0 ? void 0 : originalStyles.paddingRight) || ''; // Clean up global reference delete window.__scrollBlockerOriginalStyles; // Remove touch listener if (touchMoveListenerRef.current) { document.removeEventListener('touchmove', touchMoveListenerRef.current); touchMoveListenerRef.current = null; } } }; }, [isActive, accountForScrollbars]); // If children are provided, wrap them in TouchScrollable if (children) { return jsx(TouchScrollable, { children: children }); } return null; }; /** * A React hook that provides scroll blocking functionality. * This is useful when you want to control scroll blocking programmatically * without using the ScrollBlocker component. * * @param isBlocked - Whether scroll should be blocked * @param accountForScrollbars - Whether to account for scrollbar width * @returns An object with methods to manually block/unblock scroll */ function useScrollBlocker(isBlocked = false, accountForScrollbars = true) { const lockCounterRef = useRef(0); const originalStylesRef = useRef({ overflow: '', paddingRight: '' }); const touchMoveListenerRef = useRef(null); const blockScroll = useCallback(() => { lockCounterRef.current++; if (lockCounterRef.current === 1) { const body = document.body; originalStylesRef.current.overflow = body.style.overflow; originalStylesRef.current.paddingRight = body.style.paddingRight; const scrollbarWidth = accountForScrollbars ? getScrollbarWidth() : 0; body.style.overflow = 'hidden'; if (scrollbarWidth > 0 && accountForScrollbars) { body.style.paddingRight = `${scrollbarWidth}px`; } if (isIOS()) { const preventTouchMove = (event) => { const target = event.target; if (!target.closest('[data-scroll-lock-scrollable]')) { preventDefault(event); } }; touchMoveListenerRef.current = preventTouchMove; document.addEventListener('touchmove', preventTouchMove, { passive: false, }); } } }, [accountForScrollbars]); const unblockScroll = useCallback(() => { if (lockCounterRef.current > 0) { lockCounterRef.current--; if (lockCounterRef.current === 0) { const body = document.body; body.style.overflow = originalStylesRef.current.overflow; body.style.paddingRight = originalStylesRef.current.paddingRight; if (touchMoveListenerRef.current) { document.removeEventListener('touchmove', touchMoveListenerRef.current); touchMoveListenerRef.current = null; } } } }, []); useEffect(() => { if (isBlocked) { blockScroll(); } else { unblockScroll(); } return () => { unblockScroll(); }; }, [isBlocked, accountForScrollbars, blockScroll, unblockScroll]); // Cleanup on unmount useEffect(() => { return () => { // Force unblock when component unmounts if (lockCounterRef.current > 0) { const body = document.body; body.style.overflow = originalStylesRef.current.overflow; body.style.paddingRight = originalStylesRef.current.paddingRight; if (touchMoveListenerRef.current) { document.removeEventListener('touchmove', touchMoveListenerRef.current); } } }; }, []); return { blockScroll, unblockScroll, isBlocked: lockCounterRef.current > 0, }; } export { ScrollBlocker, TouchScrollable, ScrollBlocker as default, resetScrollBlocker, useScrollBlocker }; //# sourceMappingURL=index.esm.js.map