UNPKG

bits-ui

Version:

The headless components for Svelte.

193 lines (192 loc) 7.64 kB
import { SvelteMap } from "svelte/reactivity"; import { afterTick, box, onDestroyEffect } from "svelte-toolbelt"; import { isIOS } from "./is.js"; import { addEventListener } from "./events.js"; import { useId } from "./use-id.js"; import { watch } from "runed"; import { SharedState } from "./shared-state.svelte.js"; import { BROWSER } from "esm-env"; /** A map of lock ids to their `locked` state. */ const lockMap = new SvelteMap(); let initialBodyStyle = $state(null); let stopTouchMoveListener = null; let cleanupTimeoutId = null; const anyLocked = box.with(() => { for (const value of lockMap.values()) { if (value) return true; } return false; }); /** * We track the time we scheduled the cleanup to prevent race conditions * when multiple locks are created/destroyed in the same tick, ensuring * only the last one to schedule the cleanup will run. * * reference: https://github.com/huntabyte/bits-ui/issues/1639 */ let cleanupScheduledAt = null; const bodyLockStackCount = new SharedState(() => { function resetBodyStyle() { if (!BROWSER) return; document.body.setAttribute("style", initialBodyStyle ?? ""); document.body.style.removeProperty("--scrollbar-width"); isIOS && stopTouchMoveListener?.(); // reset initialBodyStyle so next locker captures the correct styles initialBodyStyle = null; hasEverBeenLocked = false; } function cancelPendingCleanup() { if (cleanupTimeoutId === null) return; window.clearTimeout(cleanupTimeoutId); cleanupTimeoutId = null; } function scheduleCleanupIfNoNewLocks(delay, callback) { cancelPendingCleanup(); cleanupScheduledAt = Date.now(); const currentCleanupId = cleanupScheduledAt; /** * We schedule the cleanup to run after a delay to allow new locks to register * that might have been added in the same tick as the current cleanup. * * If a new lock is added in the same tick, the cleanup will be cancelled and * a new cleanup will be scheduled. * * This is to prevent the cleanup from running too early and resetting the body * style before the new lock has had a chance to apply its styles. */ const cleanupFn = () => { cleanupTimeoutId = null; // check if this cleanup is still valid (no newer cleanups scheduled) if (cleanupScheduledAt !== currentCleanupId) return; // ensure no new locks were added during the delay if (!isAnyLocked(lockMap)) { callback(); } }; if (delay === null) { // use a small delay even when no restoreScrollDelay is set // to handle same-tick destroy/create scenarios (~1 frame) cleanupTimeoutId = window.setTimeout(cleanupFn, 16); } else { cleanupTimeoutId = window.setTimeout(cleanupFn, delay); } } // track if we've ever applied lock styles in this session let hasEverBeenLocked = false; function ensureInitialStyleCaptured() { if (!hasEverBeenLocked && initialBodyStyle === null) { initialBodyStyle = document.body.getAttribute("style"); hasEverBeenLocked = true; } } watch(() => anyLocked.current, () => { if (!anyLocked.current) return; // ensure we've captured the initial style before applying any lock styles ensureInitialStyleCaptured(); const bodyStyle = getComputedStyle(document.body); // TODO: account for RTL direction, etc. const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth; const paddingRight = Number.parseInt(bodyStyle.paddingRight ?? "0", 10); const config = { padding: paddingRight + verticalScrollbarWidth, margin: Number.parseInt(bodyStyle.marginRight ?? "0", 10), }; if (verticalScrollbarWidth > 0) { document.body.style.paddingRight = `${config.padding}px`; document.body.style.marginRight = `${config.margin}px`; document.body.style.setProperty("--scrollbar-width", `${verticalScrollbarWidth}px`); document.body.style.overflow = "hidden"; } if (isIOS) { // IOS devices are special and require a touchmove listener to prevent scrolling stopTouchMoveListener = addEventListener(document, "touchmove", (e) => { if (e.target !== document.documentElement) return; if (e.touches.length > 1) return; e.preventDefault(); }, { passive: false }); } /** * We ensure pointer-events: none is applied _after_ DOM updates, so that any focus/ * interaction changes from opening overlays/menus complete _before_ we block pointer * events. * * this avoids race conditions where pointer-events could be set too early and break * focus/interaction. */ afterTick(() => { document.body.style.pointerEvents = "none"; document.body.style.overflow = "hidden"; }); }); onDestroyEffect(() => { return () => { stopTouchMoveListener?.(); }; }); return { get lockMap() { return lockMap; }, resetBodyStyle, scheduleCleanupIfNoNewLocks, cancelPendingCleanup, ensureInitialStyleCaptured, }; }); export class BodyScrollLock { #id = useId(); #initialState; #restoreScrollDelay = () => null; #countState; locked; constructor(initialState, restoreScrollDelay = () => null) { this.#initialState = initialState; this.#restoreScrollDelay = restoreScrollDelay; this.#countState = bodyLockStackCount.get(); if (!this.#countState) return; /** * Since a new lock is being created, we cancel any pending cleanup to * prevent the cleanup from running too early and resetting the body style * before the new lock has had a chance to apply its styles. * * reference: https://github.com/huntabyte/bits-ui/issues/1639 */ this.#countState.cancelPendingCleanup(); // capture initial style before this lock is registered this.#countState.ensureInitialStyleCaptured(); this.#countState.lockMap.set(this.#id, this.#initialState ?? false); this.locked = box.with(() => this.#countState.lockMap.get(this.#id) ?? false, (v) => this.#countState.lockMap.set(this.#id, v)); onDestroyEffect(() => { this.#countState.lockMap.delete(this.#id); // if not the last lock, we don't need to do anything if (isAnyLocked(this.#countState.lockMap)) return; const restoreScrollDelay = this.#restoreScrollDelay(); /** * We schedule the cleanup to run after a delay to handle same-tick * destroy/create scenarios. * * reference: https://github.com/huntabyte/bits-ui/issues/1639 */ this.#countState.scheduleCleanupIfNoNewLocks(restoreScrollDelay, () => { this.#countState.resetBodyStyle(); }); }); } } function isAnyLocked(map) { for (const [_, value] of map) { if (value) return true; } return false; }