UNPKG

tua-body-scroll-lock

Version:

🔐Body scroll locking that just works with everything

324 lines (317 loc) 11.1 kB
/** * tua-body-scroll-lock v1.6.3 * (c) 2026 Evinma, BuptStEve * @license MIT */ var isServer = function isServer() { return typeof window === 'undefined'; }; var detectOS = function detectOS(ua) { ua = ua || navigator.userAgent; var ipad = /(iPad).*OS\s([\d_]+)/.test(ua); var iphone = !ipad && /(iPhone\sOS)\s([\d_]+)/.test(ua); var android = /(Android);?[\s/]+([\d.]+)?/.test(ua); var ios = iphone || ipad; return { ios: ios, android: android }; }; function getEventListenerOptions(options) { /* istanbul ignore if */ if (isServer()) return false; if (!options) { throw new Error('options must be provided'); } var isSupportOptions = false; var listenerOptions = { get passive() { isSupportOptions = true; return undefined; } }; /* istanbul ignore next */ // eslint-disable-next-line @typescript-eslint/no-empty-function var noop = function noop() {}; var testEvent = '__TUA_BSL_TEST_PASSIVE__'; window.addEventListener(testEvent, noop, listenerOptions); window.removeEventListener(testEvent, noop, listenerOptions); var capture = options.capture; /* istanbul ignore next */ return isSupportOptions ? options : typeof capture !== 'undefined' ? capture : false; } function noticeRequiredTargetElement(targetElement) { if (targetElement) return false; if (targetElement === null) return false; /* istanbul ignore if */ if (process.env.NODE_ENV === 'production') return false; /* istanbul ignore if */ if (process.env.NODE_ENV !== 'test') { console.warn('If scrolling is also required in the floating layer, ' + 'the target element must be provided.'); } return true; } /** * Get global function that calls preventDefault */ function getPreventEventDefault() { if ('__BSL_PREVENT_DEFAULT__' in window) { return window.__BSL_PREVENT_DEFAULT__; } window.__BSL_PREVENT_DEFAULT__ = function (event) { if (!event.cancelable) return; event.preventDefault(); }; return window.__BSL_PREVENT_DEFAULT__; } function toArray(x) { if (!x) return []; return Array.isArray(x) ? x : [x]; } /** * Check if element uses reversed flex direction */ function isReversedFlex(element) { var computedStyle = window.getComputedStyle(element); var flexDirection = computedStyle.flexDirection; return { column: flexDirection === 'column-reverse', row: flexDirection === 'row-reverse' }; } /** * Check if element is at scroll boundary * Supports both normal and reversed flex directions */ function getScrollBoundaryState(element, clientX, clientY) { var scrollTop = element.scrollTop, scrollLeft = element.scrollLeft, scrollWidth = element.scrollWidth, scrollHeight = element.scrollHeight, clientWidth = element.clientWidth, clientHeight = element.clientHeight; // Check if element uses reversed flex direction var reversed = isReversedFlex(element); // For column-reverse: visual top is at max scrollTop, visual bottom is at 0 // For normal: visual top is at 0, visual bottom is at max scrollTop var isOnTop; var isOnBottom; if (reversed.column) { // In column-reverse, scrolling down (clientY < 0) when at max scrollTop means at visual top isOnTop = clientY > 0 && Math.abs(scrollTop) + clientHeight + 1 >= scrollHeight; isOnBottom = clientY < 0 && Math.abs(scrollTop) <= 1; } else { // Normal behavior isOnTop = clientY > 0 && scrollTop === 0; isOnBottom = clientY < 0 && scrollTop + clientHeight + 1 >= scrollHeight; } // For row-reverse: visual left is at max scrollLeft, visual right is at 0 // For normal: visual left is at 0, visual right is at max scrollLeft var isOnLeft; var isOnRight; if (reversed.row) { // In row-reverse, scrolling right (clientX < 0) when at max scrollLeft means at visual left isOnLeft = clientX > 0 && Math.abs(scrollLeft) + clientWidth + 1 >= scrollWidth; isOnRight = clientX < 0 && Math.abs(scrollLeft) <= 1; } else { // Normal behavior isOnLeft = clientX > 0 && scrollLeft === 0; isOnRight = clientX < 0 && scrollLeft + clientWidth + 1 >= scrollWidth; } return { isOnTop: isOnTop, isOnBottom: isOnBottom, isOnLeft: isOnLeft, isOnRight: isOnRight }; } var initialLockState = { lockedNum: 0, lockedElements: [], unLockCallback: null, documentListenerAdded: false, initialClientPos: { clientX: 0, clientY: 0 } }; /** * get current lockState * @param options * @returns lockState */ function getLockState(options) { if (isServer()) return initialLockState; /** use local lockState */ if (!(options === null || options === void 0 ? void 0 : options.useGlobalLockState)) return getLockState.lockState; /** use global lockState */ var lockState = '__BSL_LOCK_STATE__' in window ? Object.assign(Object.assign({}, initialLockState), window.__BSL_LOCK_STATE__) : initialLockState; /** assign to global */ window.__BSL_LOCK_STATE__ = lockState; return lockState; } getLockState.lockState = initialLockState; function handleScroll(event, targetElement, initialClientPos) { if (targetElement) { var clientX = event.targetTouches[0].clientX - initialClientPos.clientX; var clientY = event.targetTouches[0].clientY - initialClientPos.clientY; var isVertical = Math.abs(clientY) > Math.abs(clientX); // Get scroll boundary state var _getScrollBoundarySta = getScrollBoundaryState(targetElement, clientX, clientY), isOnTop = _getScrollBoundarySta.isOnTop, isOnBottom = _getScrollBoundarySta.isOnBottom, isOnLeft = _getScrollBoundarySta.isOnLeft, isOnRight = _getScrollBoundarySta.isOnRight; if (isVertical && (isOnTop || isOnBottom) || !isVertical && (isOnLeft || isOnRight)) { return getPreventEventDefault()(event); } } event.stopPropagation(); return true; } function setOverflowForPc(options) { var _a; var $html = document.documentElement; var htmlStyle = Object.assign({}, $html.style); var withPaddingRight = (_a = options === null || options === void 0 ? void 0 : options.withPaddingRight) !== null && _a !== void 0 ? _a : true; var scrollBarWidth = window.innerWidth - $html.clientWidth; var previousPaddingRight = parseInt(window.getComputedStyle($html).paddingRight, 10); $html.style.overflow = 'hidden'; $html.style.boxSizing = 'border-box'; if (withPaddingRight) { $html.style.paddingRight = "".concat(scrollBarWidth + previousPaddingRight, "px"); } return function () { ['overflow', 'boxSizing', 'paddingRight'].forEach(function (x) { $html.style[x] = htmlStyle[x] || ''; }); }; } function setOverflowForMobile(options) { var $html = document.documentElement; var $body = document.body; var scrollTop = $html.scrollTop || $body.scrollTop; var htmlStyle = Object.assign({}, $html.style); var bodyStyle = Object.assign({}, $body.style); $html.style.height = '100%'; $html.style.overflow = 'hidden'; $body.style.top = "-".concat(scrollTop, "px"); $body.style.width = '100%'; $body.style.height = 'auto'; $body.style.position = 'fixed'; $body.style.overflow = (options === null || options === void 0 ? void 0 : options.overflowType) || 'hidden'; return function () { $html.style.height = htmlStyle.height || ''; $html.style.overflow = htmlStyle.overflow || ''; ['top', 'width', 'height', 'overflow', 'position'].forEach(function (x) { $body.style[x] = bodyStyle[x] || ''; }); var supportsNativeSmoothScroll = 'scrollBehavior' in document.documentElement.style; if (supportsNativeSmoothScroll) { window.scrollTo({ top: scrollTop, behavior: 'instant' }); } else { window.scrollTo(0, scrollTop); } }; } /** * lock body scroll * @param targetElement the element(s) still needs scrolling(iOS only) * @param options */ function lock(targetElement, options) { if (isServer()) return; noticeRequiredTargetElement(targetElement); var detectRes = detectOS(); var lockState = getLockState(options); if (detectRes.ios) { toArray(targetElement).filter(function (e) { return e && lockState.lockedElements.indexOf(e) === -1; }).forEach(function (element) { element.ontouchstart = function (event) { var _event$targetTouches$ = event.targetTouches[0], clientX = _event$targetTouches$.clientX, clientY = _event$targetTouches$.clientY; lockState.initialClientPos = { clientX: clientX, clientY: clientY }; }; element.ontouchmove = function (event) { handleScroll(event, element, lockState.initialClientPos); }; lockState.lockedElements.push(element); }); addTouchMoveListener(lockState); if (options === null || options === void 0 ? void 0 : options.setOverflowForIOS) { lockState.unLockCallback = setOverflowForPc(options); } } else if (lockState.lockedNum <= 0) { lockState.unLockCallback = detectRes.android ? setOverflowForMobile(options) : setOverflowForPc(options); } lockState.lockedNum += 1; } /** * unlock body scroll * @param targetElement the element(s) still needs scrolling(iOS only) * @param options */ function unlock(targetElement, options) { var _a; if (isServer()) return; noticeRequiredTargetElement(targetElement); var lockState = getLockState(options); lockState.lockedNum -= 1; if (lockState.lockedNum > 0) return; (_a = lockState.unLockCallback) === null || _a === void 0 ? void 0 : _a.call(lockState); if (!detectOS().ios) return; toArray(targetElement).forEach(function (element) { var index = lockState.lockedElements.indexOf(element); if (element && index !== -1) { element.ontouchmove = null; element.ontouchstart = null; lockState.lockedElements.splice(index, 1); } }); removeTouchMoveListener(lockState); } /** * clear all body locks * @param options */ function clearBodyLocks(options) { var _a; if (isServer()) return; var lockState = getLockState(options); lockState.lockedNum = 0; (_a = lockState.unLockCallback) === null || _a === void 0 ? void 0 : _a.call(lockState); if (!detectOS().ios) return; if (lockState.lockedElements.length) { var element = lockState.lockedElements.pop(); while (element) { element.ontouchmove = null; element.ontouchstart = null; element = lockState.lockedElements.pop(); } } removeTouchMoveListener(lockState); } function addTouchMoveListener(lockState) { if (!detectOS().ios) return; if (lockState.documentListenerAdded) return; document.addEventListener('touchmove', getPreventEventDefault(), getEventListenerOptions({ passive: false })); lockState.documentListenerAdded = true; } function removeTouchMoveListener(lockState) { if (!lockState.documentListenerAdded) return; document.removeEventListener('touchmove', getPreventEventDefault(), getEventListenerOptions({ passive: false })); lockState.documentListenerAdded = false; } export { clearBodyLocks, getLockState, lock, unlock };