UNPKG

@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
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]); }