bits-ui
Version:
The headless components for Svelte.
193 lines (192 loc) • 7.64 kB
JavaScript
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;
}