/** * Port of Base UI React's `@base-ui/utils/useScrollLock` * (`packages/utils/src/useScrollLock.ts`). * * Single global scroll lock with ref-counting, ``/body inset-scrollbar * compensation, iOS overlay-scrollbar path, and resize handling. This * supersedes the minimal `use-anchored-popup-scroll-lock` overflow toggle — * that file is now a thin wrapper that forwards to this module so existing * popover / tooltip call sites keep working without changes. * * ## React → Solid deviations * * - The React hook body uses `useIsoLayoutEffect` — here it maps to * `createRenderEffect` via the `useIsoLayoutEffect` alias. The compute * half re-reads `enabled` / `referenceElement` and the apply half * returns a disposer; Solid's effect machinery invokes the previous * disposer on every re-run and on owner dispose, mirroring React's * useEffect cleanup semantics. * - `Accessor | T` is accepted for both inputs so consumers don't have * to wrap bare values. Matches the hook-parameter shape elsewhere in * the port. * - `addEventListener` is inlined (one-off use for the window resize * listener) since this codebase doesn't have a dedicated helper. * * ## happy-dom gaps * * Real-browser-only paths that unit tests cannot exercise: * * - **Inset-scrollbar compensation**: `isOverflowElement(html)` + `body` * height/width rewriting requires real layout metrics. happy-dom * returns 0 for `offsetWidth` / `clientWidth` — the inset branch runs * but writes zero-valued styles. * - **iOS overlay-scrollbar path**: gated on `isIOS`; test env reports * `false` so the path is inert. * - **`scrollbar-gutter: stable` detection**: relies on `CSS.supports` + * real `offsetWidth` diffs; result in happy-dom is environment- * dependent. * - **`visualViewport.scale` pinch-zoom bail-out**: happy-dom has no * `visualViewport`. * - **Window `resize` event**: happy-dom dispatches events but doesn't * emit native layout changes. * * The unit tests in `test/internals/popups/use-scroll-lock.test.tsx` * cover the ref-counting state machine, the `overflow: hidden` / `clip` * site-author bailout, and basic enable/disable lifecycle. */ import { isOverflowElement } from "@floating-ui/utils/dom"; import { type Accessor, createRenderEffect } from "solid-js"; import { ownerDocument } from "../dom/owner-document"; import { ownerWindow } from "../dom/owner-window"; // `isSafari` stands in for `isWebKit` here — the Solid port's // `detect-browser` only exports `isSafari` (navigator.vendor check), which // overlaps the WebKit path for the purposes of the pinch-zoom bail-out. // A dedicated `isWebKit` would land as a separate internals update. import { isIOS, isSafari as isWebKit } from "../floating-ui/detect-browser"; import { AnimationFrame } from "../use-animation-frame"; import { Timeout } from "../use-timeout"; const NOOP = (): void => {}; // Module-global state — matches the React source's single `ScrollLocker` // instance + top-level `originalHtmlStyles` / `originalBodyStyles` // caches. Ref-counting is shared across all components that acquire a // lock, so two simultaneously-open dialogs acquire the lock once and // release it once. let originalHtmlStyles: Partial = {}; let originalBodyStyles: Partial = {}; let originalHtmlScrollBehavior = ""; function hasInsetScrollbars(referenceElement: Element | null): boolean { if (typeof document === "undefined") { return false; } const doc = ownerDocument(referenceElement); const win = ownerWindow(doc.documentElement); return win.innerWidth - doc.documentElement.clientWidth > 0; } function supportsStableScrollbarGutter( referenceElement: Element | null, ): boolean { const supported = typeof CSS !== "undefined" && CSS.supports && CSS.supports("scrollbar-gutter", "stable"); if (!supported || typeof document === "undefined") { return false; } const doc = ownerDocument(referenceElement); const html = doc.documentElement; const body = doc.body; const scrollContainer = isOverflowElement(html) ? 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; } /** * Overlay-scrollbar path — used on iOS and whenever the document has no * inset scrollbars. Locks the overflowing element's `overflow-x` / * `overflow-y` to `hidden` without attempting body geometry compensation. */ function preventScrollOverlayScrollbars( referenceElement: Element | null, ): () => void { const doc = ownerDocument(referenceElement); const html = doc.documentElement; const body = doc.body; const elementToLock = isOverflowElement(html) ? html : body; const originalElementToLockStyles = { overflowY: elementToLock.style.overflowY, overflowX: elementToLock.style.overflowX, }; Object.assign(elementToLock.style, { overflowY: "hidden", overflowX: "hidden", }); return (): void => { Object.assign(elementToLock.style, originalElementToLockStyles); }; } /** * Inset-scrollbar path — used when the document has classic inset * scrollbars (most desktops). Rewrites `` and `` to lock the * scroll position while preserving scrollbar width through either * `scrollbar-gutter: stable` or a `calc(100dvh - ...)` / `calc(100vw - ...)` * body clamp. Mirrors the React source 1:1. */ function preventScrollInsetScrollbars( referenceElement: Element | null, ): () => void { const doc = ownerDocument(referenceElement); const html = doc.documentElement; const body = doc.body; const win = ownerWindow(html); let scrollTop = 0; let scrollLeft = 0; let updateGutterOnly = false; 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 NOOP; } function lockScroll(): void { /* 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); const marginY = parseFloat(bodyStyles.marginTop) + parseFloat(bodyStyles.marginBottom); const marginX = parseFloat(bodyStyles.marginLeft) + parseFloat(bodyStyles.marginRight); const elementToLock = isOverflowElement(html) ? 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", overflow: "hidden", scrollBehavior: "unset", }); body.scrollTop = scrollTop; body.scrollLeft = scrollLeft; html.setAttribute("data-quoin-scroll-locked", ""); html.style.scrollBehavior = "unset"; } function cleanup(): void { Object.assign(html.style, originalHtmlStyles); Object.assign(body.style, originalBodyStyles); if (!updateGutterOnly) { html.scrollTop = scrollTop; html.scrollLeft = scrollLeft; html.removeAttribute("data-quoin-scroll-locked"); html.style.scrollBehavior = originalHtmlScrollBehavior; } } function handleResize(): void { cleanup(); resizeFrame.request(lockScroll); } lockScroll(); win.addEventListener("resize", handleResize); return (): void => { resizeFrame.cancel(); cleanup(); // Sometimes this cleanup can run after test teardown. Guard so we // don't crash. if (typeof win.removeEventListener === "function") { win.removeEventListener("resize", handleResize); } }; } /** * Singleton ref-counted lock. First acquire installs the lock (via a * `setTimeout(fn, 0)` so simultaneous opens coalesce); last release * schedules cleanup on another 0-delay timeout so enter/exit animations * with brief re-enables don't thrash. */ class ScrollLocker { lockCount = 0; restore: (() => void) | null = null; timeoutLock = Timeout.create(); timeoutUnlock = Timeout.create(); acquire(referenceElement: Element | null): () => void { this.lockCount += 1; if (this.lockCount === 1 && this.restore === null) { this.timeoutLock.start(0, () => this.lock(referenceElement)); } return this.release; } release = (): void => { this.lockCount -= 1; if (this.lockCount === 0 && this.restore) { this.timeoutUnlock.start(0, this.unlock); } }; private unlock = (): void => { if (this.lockCount === 0 && this.restore) { this.restore(); this.restore = null; } }; private lock(referenceElement: Element | null): void { if (this.lockCount === 0 || this.restore !== null) { return; } if (typeof document === "undefined") { this.restore = NOOP; return; } const doc = ownerDocument(referenceElement); const html = doc.documentElement; const htmlOverflowY = ownerWindow(html).getComputedStyle(html).overflowY; // If the site author already hid overflow on , respect it and bail. if (htmlOverflowY === "hidden" || htmlOverflowY === "clip") { this.restore = NOOP; return; } const hasOverlayScrollbars = isIOS || !hasInsetScrollbars(referenceElement); // On iOS, scroll locking does not work if the navbar is collapsed. // See the React source's comment for the history of issues that // gate iOS-specific behavior. this.restore = hasOverlayScrollbars ? preventScrollOverlayScrollbars(referenceElement) : preventScrollInsetScrollbars(referenceElement); } } const SCROLL_LOCKER = new ScrollLocker(); /** * Inputs for `useScrollLock`. Both parameters accept a raw value or a * Solid `Accessor` — passing an accessor lets the hook react to prop * changes without the caller needing to set up their own effect. */ export interface UseScrollLockParameters { /** Whether scroll lock is enabled. */ enabled?: boolean | Accessor | undefined; /** * The element used as a reference for lock calculations — typically * the popup / positioner. The owner document/window are inferred off * of this. */ referenceElement?: Element | null | Accessor | undefined; } function resolve(input: T | Accessor): T { return typeof input === "function" ? (input as Accessor)() : input; } /** * Locks the scroll of the document when `enabled` is true. Ref-counted * globally — multiple simultaneous callers share one lock/unlock cycle. * * Usage: * * ```ts * useScrollLock({ * enabled: () => props.modal === true && open(), * referenceElement: popupElement, * }); * ``` */ export function useScrollLock(parameters: UseScrollLockParameters = {}): void { createRenderEffect( () => ({ enabled: resolve(parameters.enabled ?? true), reference: resolve(parameters.referenceElement ?? null), }), ({ enabled, reference }) => { if (!enabled) return undefined; const release = SCROLL_LOCKER.acquire(reference); return release; }, ); } /** * Test-only peek into the lock state. Not part of the public hook * surface; tests import it directly. * * @internal */ export function __peekScrollLockerState(): { count: number; locked: boolean; } { return { count: SCROLL_LOCKER.lockCount, locked: SCROLL_LOCKER.restore !== null, }; } /** * Test-only reset. Forces the singleton back to its pristine state so * tests don't leak ref counts across each other. * * @internal */ export function __resetScrollLockerForTests(): void { SCROLL_LOCKER.timeoutLock.clear(); SCROLL_LOCKER.timeoutUnlock.clear(); if (SCROLL_LOCKER.restore) { SCROLL_LOCKER.restore(); SCROLL_LOCKER.restore = null; } SCROLL_LOCKER.lockCount = 0; }