@base-ui-components/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
209 lines (196 loc) • 7.77 kB
JavaScript
import { isIOS, isWebKit } from '@base-ui-components/utils/detectBrowser';
import { ownerDocument, ownerWindow } from '@base-ui-components/utils/owner';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { Timeout } from '@base-ui-components/utils/useTimeout';
import { AnimationFrame } from '@base-ui-components/utils/useAnimationFrame';
import { NOOP } from "./noop.js";
/* eslint-disable lines-between-class-members */
let originalHtmlStyles = {};
let originalBodyStyles = {};
let originalHtmlScrollBehavior = '';
function hasInsetScrollbars(referenceElement) {
if (typeof document === 'undefined') {
return false;
}
const doc = ownerDocument(referenceElement);
const win = ownerWindow(doc);
return win.innerWidth - doc.documentElement.clientWidth > 0;
}
function preventScrollBasic(referenceElement) {
const doc = ownerDocument(referenceElement);
const html = doc.documentElement;
const originalOverflow = html.style.overflow;
html.style.overflow = 'hidden';
return () => {
html.style.overflow = originalOverflow;
};
}
function preventScrollStandard(referenceElement) {
const doc = ownerDocument(referenceElement);
const html = doc.documentElement;
const body = doc.body;
const win = ownerWindow(html);
let scrollTop = 0;
let scrollLeft = 0;
const resizeFrame = AnimationFrame.create();
// Pinch-zoom in Safari causes a shift. Just don't lock scroll if there's any pinch-zoom.
if (isWebKit && (win.visualViewport?.scale ?? 1) !== 1) {
return () => {};
}
function lockScroll() {
/* DOM reads: */
const htmlStyles = win.getComputedStyle(html);
const bodyStyles = win.getComputedStyle(body);
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
};
// Handle `scrollbar-gutter` in Chrome when there is no scrollable content.
const supportsStableScrollbarGutter = typeof CSS !== 'undefined' && CSS.supports?.('scrollbar-gutter', 'stable');
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 - html.clientWidth);
const scrollbarHeight = Math.max(0, win.innerHeight - html.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);
/*
* DOM writes:
* Do not read the DOM past this point!
*/
Object.assign(html.style, {
scrollbarGutter: 'stable',
overflowY: !supportsStableScrollbarGutter && (isScrollableY || hasConstantOverflowY) ? 'scroll' : 'hidden',
overflowX: !supportsStableScrollbarGutter && (isScrollableX || hasConstantOverflowX) ? 'scroll' : 'hidden'
});
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',
overflow: '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);
html.scrollTop = scrollTop;
html.scrollLeft = scrollLeft;
html.removeAttribute('data-base-ui-scroll-locked');
html.style.scrollBehavior = originalHtmlScrollBehavior;
}
function handleResize() {
cleanup();
resizeFrame.request(lockScroll);
}
lockScroll();
win.addEventListener('resize', handleResize);
return () => {
resizeFrame.cancel();
cleanup();
win.removeEventListener('resize', handleResize);
};
}
class ScrollLocker {
lockCount = 0;
restore = (() => null)();
timeoutLock = (() => Timeout.create())();
timeoutUnlock = (() => 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 = ownerDocument(referenceElement);
const html = doc.documentElement;
const htmlOverflowY = ownerWindow(html).getComputedStyle(html).overflowY;
// If the site author already hid overflow on <html>, respect it and bail out.
if (htmlOverflowY === 'hidden' || htmlOverflowY === 'clip') {
this.restore = NOOP;
return;
}
const isOverflowHiddenLock = isIOS || !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 = isOverflowHiddenLock ? preventScrollBasic(referenceElement) : preventScrollStandard(referenceElement);
}
}
const SCROLL_LOCKER = new ScrollLocker();
/**
* Locks the scroll of the document when enabled.
*
* @param enabled - Whether to enable the scroll lock.
*/
export function useScrollLock(params) {
const {
enabled = true,
mounted,
open,
referenceElement = null
} = params;
// https://github.com/mui/base-ui/issues/1135
useIsoLayoutEffect(() => {
if (enabled && isWebKit && mounted && !open) {
const doc = ownerDocument(referenceElement);
const originalUserSelect = doc.body.style.userSelect;
const originalWebkitUserSelect = doc.body.style.webkitUserSelect;
doc.body.style.userSelect = 'none';
doc.body.style.webkitUserSelect = 'none';
return () => {
doc.body.style.userSelect = originalUserSelect;
doc.body.style.webkitUserSelect = originalWebkitUserSelect;
};
}
return undefined;
}, [enabled, mounted, open, referenceElement]);
useIsoLayoutEffect(() => {
if (!enabled) {
return undefined;
}
return SCROLL_LOCKER.acquire(referenceElement);
}, [enabled, referenceElement]);
}