Files
2026-08-07 21:39:30 +02:00

278 lines
11 KiB
JavaScript

"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 <html> when it establishes its own scroll container, and
// propagates from <body> 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 <html>, we need to lock it, because a lock on <body>
// won't have any effect.
// But if <body> 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 <body> margin. This does cause elements to be clipped
// with whitespace. Warn if <body> 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]);
}