/** * Port of Base UI React's `useAnchoredPopupScrollLock` * (`packages/react/src/utils/useAnchoredPopupScrollLock.ts`). * * Manages scroll lock for anchored popups: * * - Non-touch opens: scroll lock is applied whenever `enabled` is true. * - Touch opens: scroll lock is applied only when the positioner is * effectively viewport-sized, so users can still swipe outside a small * popup to dismiss it. * * "Effectively viewport-sized" means the popup's width is within * `VIEWPORT_WIDTH_TOLERANCE_PX` of the document width — enough slack that * common ~10px side padding still locks scroll without leaving a useful * swipe target. * * Delegates to the full `useScrollLock` in `./use-scroll-lock` — which * handles iOS quirks, inset-scrollbar compensation, and layout-shift- * aware body compensation. This wrapper only adds the touch-open * viewport-width gate on top. * * ## React → Solid deviations * * - Accepts reactive accessors for every input. React re-ran the hook on * prop changes via its render cycle; Solid uses split `createRenderEffect` * so the computation re-fires when any tracked input changes. */ import { type Accessor, createRenderEffect, createSignal } from "solid-js"; import { ownerDocument } from "../dom/owner-document"; import { __peekScrollLockerState, useScrollLock } from "./use-scroll-lock"; const VIEWPORT_WIDTH_TOLERANCE_PX = 20; /** * Locks scroll while a modal anchored popup is open. On touch opens the * lock is conditional on the popup being effectively full-width, so small * touch-opened popups remain swipe-dismissable. * * Every parameter is an accessor — pass getter arrows so the hook reacts * to prop changes: * * ```ts * useAnchoredPopupScrollLock({ * enabled: () => props.modal, * touchOpen: () => openInteractionType() === "touch", * positionerElement, * referenceElement, * }); * ``` */ export interface UseAnchoredPopupScrollLockParameters { enabled: Accessor | boolean; touchOpen: Accessor | boolean; positionerElement: Accessor; referenceElement: Accessor; } function resolve(input: T | Accessor): T { return typeof input === "function" ? (input as Accessor)() : input; } export function useAnchoredPopupScrollLock( parameters: UseAnchoredPopupScrollLockParameters, ): void { const [touchOpenShouldLockScroll, setTouchOpenShouldLockScroll] = createSignal(false, { ownedWrite: true }); // Compute: should the touch-open gate flip the lock on? Split-effect so // the compute half tracks the reactive inputs and the apply half does // the one-shot measurement + state write. Mirrors React's // `useIsoLayoutEffect` dependency list [enabled, touchOpen, positioner]. createRenderEffect( () => ({ enabled: resolve(parameters.enabled), touchOpen: resolve(parameters.touchOpen), positioner: parameters.positionerElement(), }), ({ enabled, touchOpen, positioner }) => { if (!enabled || !touchOpen || positioner == null) { setTouchOpenShouldLockScroll(false); return; } const viewportWidth = ownerDocument(positioner).documentElement.clientWidth; const popupWidth = positioner.offsetWidth; setTouchOpenShouldLockScroll( viewportWidth > 0 && popupWidth > 0 && popupWidth >= viewportWidth - VIEWPORT_WIDTH_TOLERANCE_PX, ); }, ); // Delegate to the full scroll-lock — picks up iOS, inset-scrollbar // compensation, scrollbar-gutter detection, resize handling. useScrollLock({ enabled: () => { const enabled = resolve(parameters.enabled); const touchOpen = resolve(parameters.touchOpen); const shouldLockViaTouch = touchOpenShouldLockScroll(); return enabled && (!touchOpen || shouldLockViaTouch); }, referenceElement: () => parameters.referenceElement(), }); } /** * Exposed for tests: current lock count + installed restore flag. Forwards * to the full scroll-locker's state. * * @internal */ export function __peekScrollLockState(): { count: number; locked: boolean } { return __peekScrollLockerState(); }