"use strict"; 'use client'; Object.defineProperty(exports, "__esModule", { value: true }); exports.useScrollLock = useScrollLock; var _dom = require("@floating-ui/utils/dom"); var _addEventListener = require("./addEventListener"); var _platform = require("./platform"); var _owner = require("./owner"); var _useIsoLayoutEffect = require("./useIsoLayoutEffect"); var _useTimeout = require("./useTimeout"); var _useAnimationFrame = require("./useAnimationFrame"); let originalHtmlStyles = {}; let originalBodyStyles = {}; let originalHtmlScrollBehavior = ''; // The viewport's overflow comes from when it establishes its own scroll container, and // propagates from otherwise. An `overflow` style on the other element doesn't lock the page. function getViewportScroller(html, body) { return (0, _dom.isOverflowElement)(html) ? html : body; } function isPageScrollLocked(win, html, body) { return /hidden|clip/.test(win.getComputedStyle(getViewportScroller(html, body)).overflowY); } function hasInsetScrollbars(referenceElement) { if (typeof document === 'undefined') { return false; } const doc = (0, _owner.ownerDocument)(referenceElement); const win = (0, _owner.ownerWindow)(doc); return win.innerWidth - doc.documentElement.clientWidth > 0; } function supportsStableScrollbarGutter(referenceElement) { const supported = typeof CSS !== 'undefined' && CSS.supports && CSS.supports('scrollbar-gutter', 'stable'); if (!supported || typeof document === 'undefined') { return false; } const doc = (0, _owner.ownerDocument)(referenceElement); const html = doc.documentElement; const body = doc.body; const scrollContainer = getViewportScroller(html, body); const originalScrollContainerOverflowY = scrollContainer.style.overflowY; const originalHtmlStyleGutter = html.style.scrollbarGutter; html.style.scrollbarGutter = 'stable'; scrollContainer.style.overflowY = 'scroll'; const before = scrollContainer.offsetWidth; scrollContainer.style.overflowY = 'hidden'; const after = scrollContainer.offsetWidth; scrollContainer.style.overflowY = originalScrollContainerOverflowY; html.style.scrollbarGutter = originalHtmlStyleGutter; return before === after; } function preventScrollOverlayScrollbars(referenceElement) { const doc = (0, _owner.ownerDocument)(referenceElement); const html = doc.documentElement; const body = doc.body; // If an `overflow` style is present on , we need to lock it, because a lock on // won't have any effect. // But if has an `overflow` style (like `overflow-x: hidden`), we need to lock it // instead, as sticky elements shift otherwise. const elementToLock = getViewportScroller(html, body); const originalElementToLockStyles = { overflowY: elementToLock.style.overflowY, overflowX: elementToLock.style.overflowX }; Object.assign(elementToLock.style, { overflowY: 'hidden', overflowX: 'hidden' }); return () => { Object.assign(elementToLock.style, originalElementToLockStyles); }; } function preventScrollInsetScrollbars(referenceElement) { const doc = (0, _owner.ownerDocument)(referenceElement); const html = doc.documentElement; const body = doc.body; const win = (0, _owner.ownerWindow)(html); let scrollTop = 0; let scrollLeft = 0; let updateGutterOnly = false; const resizeFrame = _useAnimationFrame.AnimationFrame.create(); // Pinch-zoom in Safari causes a shift. Just don't lock scroll if there's any pinch-zoom. if (_platform.platform.engine.webkit && (win.visualViewport?.scale ?? 1) !== 1) { return () => {}; } function lockScroll() { /* DOM reads: */ const htmlStyles = win.getComputedStyle(html); const bodyStyles = win.getComputedStyle(body); const htmlScrollbarGutterValue = htmlStyles.scrollbarGutter || ''; const hasBothEdges = htmlScrollbarGutterValue.includes('both-edges'); const scrollbarGutterValue = hasBothEdges ? 'stable both-edges' : 'stable'; scrollTop = html.scrollTop; scrollLeft = html.scrollLeft; originalHtmlStyles = { scrollbarGutter: html.style.scrollbarGutter, overflowY: html.style.overflowY, overflowX: html.style.overflowX }; originalHtmlScrollBehavior = html.style.scrollBehavior; originalBodyStyles = { position: body.style.position, height: body.style.height, width: body.style.width, boxSizing: body.style.boxSizing, overflowY: body.style.overflowY, overflowX: body.style.overflowX, scrollBehavior: body.style.scrollBehavior }; const isScrollableY = html.scrollHeight > html.clientHeight; const isScrollableX = html.scrollWidth > html.clientWidth; const hasConstantOverflowY = htmlStyles.overflowY === 'scroll' || bodyStyles.overflowY === 'scroll'; const hasConstantOverflowX = htmlStyles.overflowX === 'scroll' || bodyStyles.overflowX === 'scroll'; // Values can be negative in Firefox const scrollbarWidth = Math.max(0, win.innerWidth - body.clientWidth); const scrollbarHeight = Math.max(0, win.innerHeight - body.clientHeight); // Avoid shift due to the default margin. This does cause elements to be clipped // with whitespace. Warn if has margins? const marginY = parseFloat(bodyStyles.marginTop) + parseFloat(bodyStyles.marginBottom); const marginX = parseFloat(bodyStyles.marginLeft) + parseFloat(bodyStyles.marginRight); const elementToLock = getViewportScroller(html, body); updateGutterOnly = supportsStableScrollbarGutter(referenceElement); /* * DOM writes: * Do not read the DOM past this point! */ if (updateGutterOnly) { html.style.scrollbarGutter = scrollbarGutterValue; elementToLock.style.overflowY = 'hidden'; elementToLock.style.overflowX = 'hidden'; return; } Object.assign(html.style, { scrollbarGutter: scrollbarGutterValue, overflowY: 'hidden', overflowX: 'hidden' }); if (isScrollableY || hasConstantOverflowY) { html.style.overflowY = 'scroll'; } if (isScrollableX || hasConstantOverflowX) { html.style.overflowX = 'scroll'; } Object.assign(body.style, { position: 'relative', height: marginY || scrollbarHeight ? `calc(100dvh - ${marginY + scrollbarHeight}px)` : '100dvh', width: marginX || scrollbarWidth ? `calc(100vw - ${marginX + scrollbarWidth}px)` : '100vw', boxSizing: 'border-box', // Assign the longhands that `cleanup` restores, so nothing is left behind. overflowY: 'hidden', overflowX: 'hidden', scrollBehavior: 'unset' }); body.scrollTop = scrollTop; body.scrollLeft = scrollLeft; html.setAttribute('data-base-ui-scroll-locked', ''); html.style.scrollBehavior = 'unset'; } function cleanup() { Object.assign(html.style, originalHtmlStyles); Object.assign(body.style, originalBodyStyles); if (!updateGutterOnly) { html.scrollTop = scrollTop; html.scrollLeft = scrollLeft; html.removeAttribute('data-base-ui-scroll-locked'); html.style.scrollBehavior = originalHtmlScrollBehavior; } } function handleResize() { cleanup(); resizeFrame.request(lockScroll); } lockScroll(); const unsubscribeResize = (0, _addEventListener.addEventListener)(win, 'resize', handleResize); return () => { resizeFrame.cancel(); cleanup(); // Sometimes this cleanup can run after test teardown because it is called // in a `setTimeout(fn, 0)`. Guard the returned cleanup to avoid calling // `removeEventListener` when it is no longer available in tests. if (typeof win.removeEventListener === 'function') { unsubscribeResize(); } }; } class ScrollLocker { lockCount = 0; restore = null; timeoutLock = _useTimeout.Timeout.create(); timeoutUnlock = _useTimeout.Timeout.create(); acquire(referenceElement) { this.lockCount += 1; if (this.lockCount === 1 && this.restore === null) { this.timeoutLock.start(0, () => this.lock(referenceElement)); } return this.release; } release = () => { this.lockCount -= 1; if (this.lockCount === 0 && this.restore) { this.timeoutUnlock.start(0, this.unlock); } }; unlock = () => { if (this.lockCount === 0 && this.restore) { this.restore?.(); this.restore = null; } }; lock(referenceElement) { if (this.lockCount === 0 || this.restore !== null) { return; } const doc = (0, _owner.ownerDocument)(referenceElement); const html = doc.documentElement; const body = doc.body; const win = (0, _owner.ownerWindow)(html); // The page is already locked, either by the site author or by a non-Base UI overlay that // hasn't cleaned up yet. Leave it alone and wait for the lock to clear before taking over, // otherwise we'd snapshot the locked state and restore it after our own lock is released. if (isPageScrollLocked(win, html, body)) { const observer = new win.MutationObserver(() => { if (isPageScrollLocked(win, html, body)) { return; } observer.disconnect(); this.restore = null; this.lock(referenceElement); }); // Watch every attribute: locks are applied through inline styles, classes, or attributes // paired with a stylesheet (`data-scroll-locked` in react-remove-scroll, for example). const options = { attributes: true }; observer.observe(html, options); observer.observe(body, options); this.restore = () => observer.disconnect(); return; } const hasOverlayScrollbars = _platform.platform.os.ios || !hasInsetScrollbars(referenceElement); // On iOS, scroll locking does not work if the navbar is collapsed. Due to numerous // side effects and bugs that arise on iOS, it must be researched extensively before // being enabled to ensure it doesn't cause the following issues: // - Textboxes must scroll into view when focused, nor cause a glitchy scroll animation. // - The navbar must not force itself into view and cause layout shift. // - Scroll containers must not flicker upon closing a popup when it has an exit animation. this.restore = hasOverlayScrollbars ? preventScrollOverlayScrollbars(referenceElement) : preventScrollInsetScrollbars(referenceElement); } } const SCROLL_LOCKER = new ScrollLocker(); /** * Locks the scroll of the document when enabled. * * @param enabled - Whether to enable the scroll lock. * @param referenceElement - Element to use as a reference for lock calculations. */ function useScrollLock(enabled = true, referenceElement = null) { (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { if (!enabled) { return undefined; } return SCROLL_LOCKER.acquire(referenceElement); }, [enabled, referenceElement]); }