You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
262 lines
6.9 KiB
262 lines
6.9 KiB
import { SvelteMap } from 'svelte/reactivity';
|
|
|
|
import { writableActive, type State } from '$reactive';
|
|
import { isBrowser, isIOS } from '$libs/dom';
|
|
import type { ActiveDom } from './active-dom.svelte';
|
|
|
|
/**
|
|
* Options reserved for future expansion (scrollbar padding/margin compensation
|
|
* strategies). Kept as an exported type so consumers can type-forward.
|
|
*/
|
|
export type BodyScrollLockOption = Record<string, never>;
|
|
|
|
export interface BodyScrollLockRuntimeOptions {
|
|
dom?: Pick<ActiveDom, 'getDocument' | 'getWindow' | 'listen'>;
|
|
}
|
|
|
|
/**
|
|
* Properties we mutate on `<body>` while locked. Must all be saved so we can
|
|
* restore the exact pre-lock state when the last lock releases.
|
|
*/
|
|
const MANAGED_PROPERTIES = [
|
|
'overflow',
|
|
'overflow-x',
|
|
'overflow-y',
|
|
'padding-right',
|
|
'--scrollbar-width'
|
|
] as const;
|
|
|
|
type ManagedProperty = (typeof MANAGED_PROPERTIES)[number];
|
|
type SavedProperties = Partial<Record<ManagedProperty, string>>;
|
|
|
|
/**
|
|
* Module-singleton state coordinating every `BodyScrollLock` instance against
|
|
* the single `<body>` element. Allocated lazily on first access so importing
|
|
* this module is side-effect-free — the `SvelteMap` is built only when a lock
|
|
* is actually constructed (or `BodyScrollLock.reset()` is called from tests).
|
|
*/
|
|
type ModuleState = {
|
|
lockMap: SvelteMap<string, boolean>;
|
|
savedProperties: SavedProperties | null;
|
|
stopTouchMoveListener: (() => void) | null;
|
|
cleanupTimeoutId: number | null;
|
|
cleanupScheduledAt: number | null;
|
|
idCounter: number;
|
|
dom: BodyScrollLockRuntimeOptions['dom'];
|
|
};
|
|
|
|
let _state: ModuleState | undefined;
|
|
|
|
function state(): ModuleState {
|
|
return (_state ??= {
|
|
lockMap: new SvelteMap<string, boolean>(),
|
|
savedProperties: null,
|
|
stopTouchMoveListener: null,
|
|
cleanupTimeoutId: null,
|
|
cleanupScheduledAt: null,
|
|
idCounter: 0,
|
|
dom: undefined
|
|
});
|
|
}
|
|
|
|
function activeDocument(): Document {
|
|
return state().dom?.getDocument() ?? document;
|
|
}
|
|
|
|
function activeWindow(): Window {
|
|
return state().dom?.getWindow() ?? window;
|
|
}
|
|
|
|
function listen(
|
|
target: EventTarget,
|
|
event: string | readonly string[],
|
|
handler: EventListener,
|
|
options?: boolean | AddEventListenerOptions
|
|
): () => void {
|
|
const dom = state().dom;
|
|
if (dom) return dom.listen(target, event, handler, options);
|
|
const events = Array.isArray(event) ? event : [event];
|
|
for (const e of events) target.addEventListener(e, handler, options);
|
|
return () => {
|
|
for (const e of events) target.removeEventListener(e, handler, options);
|
|
};
|
|
}
|
|
|
|
function nextId(): string {
|
|
const s = state();
|
|
s.idCounter += 1;
|
|
return `body-scroll-lock-${s.idCounter}`;
|
|
}
|
|
|
|
function isAnyLocked(): boolean {
|
|
for (const [, value] of state().lockMap) {
|
|
if (value) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function cancelPendingCleanup() {
|
|
const s = state();
|
|
if (s.cleanupTimeoutId === null || !isBrowser) return;
|
|
activeWindow().clearTimeout(s.cleanupTimeoutId);
|
|
s.cleanupTimeoutId = null;
|
|
}
|
|
|
|
function captureBodyProperties() {
|
|
const s = state();
|
|
if (!isBrowser || s.savedProperties !== null) return;
|
|
const style = activeDocument().body.style;
|
|
const saved: SavedProperties = {};
|
|
for (const prop of MANAGED_PROPERTIES) {
|
|
const value = style.getPropertyValue(prop);
|
|
if (value) saved[prop] = value;
|
|
}
|
|
s.savedProperties = saved;
|
|
}
|
|
|
|
function restoreBodyProperties() {
|
|
if (!isBrowser) return;
|
|
const s = state();
|
|
const style = activeDocument().body.style;
|
|
const saved = s.savedProperties ?? {};
|
|
for (const prop of MANAGED_PROPERTIES) {
|
|
const original = saved[prop];
|
|
if (original === undefined) {
|
|
style.removeProperty(prop);
|
|
} else {
|
|
style.setProperty(prop, original);
|
|
}
|
|
}
|
|
detachTouchMoveListener();
|
|
s.savedProperties = null;
|
|
}
|
|
|
|
function detachTouchMoveListener() {
|
|
const s = state();
|
|
s.stopTouchMoveListener?.();
|
|
s.stopTouchMoveListener = null;
|
|
}
|
|
|
|
function attachTouchMoveListener() {
|
|
const s = state();
|
|
if (!isBrowser || !isIOS || s.stopTouchMoveListener) return;
|
|
const doc = activeDocument();
|
|
|
|
const listener = (event: TouchEvent) => {
|
|
if (event.target !== doc.documentElement) return;
|
|
if (event.touches.length > 1) return;
|
|
event.preventDefault();
|
|
};
|
|
|
|
s.stopTouchMoveListener = listen(doc, 'touchmove', listener as EventListener, { passive: false });
|
|
const teardown = s.stopTouchMoveListener;
|
|
s.stopTouchMoveListener = () => {
|
|
teardown();
|
|
state().stopTouchMoveListener = null;
|
|
};
|
|
}
|
|
|
|
function applyBodyLock() {
|
|
if (!isBrowser) return;
|
|
|
|
cancelPendingCleanup();
|
|
captureBodyProperties();
|
|
|
|
const doc = activeDocument();
|
|
const win = activeWindow();
|
|
const style = doc.body.style;
|
|
const htmlStyle = win.getComputedStyle(doc.documentElement);
|
|
const bodyStyle = win.getComputedStyle(doc.body);
|
|
const hasStableGutter =
|
|
htmlStyle.scrollbarGutter?.includes('stable') || bodyStyle.scrollbarGutter?.includes('stable');
|
|
const verticalScrollbarWidth = win.innerWidth - doc.documentElement.clientWidth;
|
|
const paddingRight = Number.parseInt(bodyStyle.paddingRight || '0', 10);
|
|
|
|
if (verticalScrollbarWidth > 0 && !hasStableGutter) {
|
|
style.paddingRight = `${paddingRight + verticalScrollbarWidth}px`;
|
|
style.setProperty('--scrollbar-width', `${verticalScrollbarWidth}px`);
|
|
}
|
|
|
|
style.overflow = 'hidden';
|
|
attachTouchMoveListener();
|
|
}
|
|
|
|
function scheduleCleanupIfNoNewLocks(delay: number | null, callback: () => void) {
|
|
if (!isBrowser) return;
|
|
|
|
cancelPendingCleanup();
|
|
|
|
const s = state();
|
|
s.cleanupScheduledAt = Date.now();
|
|
const currentCleanupId = s.cleanupScheduledAt;
|
|
|
|
const cleanupFn = () => {
|
|
const inner = state();
|
|
inner.cleanupTimeoutId = null;
|
|
if (inner.cleanupScheduledAt !== currentCleanupId) return;
|
|
|
|
if (!isAnyLocked()) {
|
|
callback();
|
|
}
|
|
};
|
|
|
|
s.cleanupTimeoutId = activeWindow().setTimeout(cleanupFn, delay ?? 24);
|
|
}
|
|
|
|
export class BodyScrollLock {
|
|
readonly id = nextId();
|
|
readonly locked: State<boolean>;
|
|
|
|
constructor(
|
|
initialState?: boolean,
|
|
private readonly restoreScrollDelay: () => number | null = () => null,
|
|
options: BodyScrollLockRuntimeOptions = {}
|
|
) {
|
|
state().dom = options.dom;
|
|
state().lockMap.set(this.id, initialState ?? false);
|
|
|
|
this.locked = writableActive(
|
|
() => state().lockMap.get(this.id) ?? false,
|
|
(value: boolean) => {
|
|
state().lockMap.set(this.id, value);
|
|
|
|
if (value || isAnyLocked()) {
|
|
applyBodyLock();
|
|
return;
|
|
}
|
|
|
|
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), restoreBodyProperties);
|
|
}
|
|
);
|
|
|
|
if (initialState) applyBodyLock();
|
|
}
|
|
|
|
destroy() {
|
|
const s = state();
|
|
const wasLocked = s.lockMap.get(this.id) ?? false;
|
|
s.lockMap.delete(this.id);
|
|
|
|
if (isAnyLocked()) {
|
|
applyBodyLock();
|
|
return;
|
|
}
|
|
|
|
if (wasLocked) {
|
|
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), restoreBodyProperties);
|
|
}
|
|
}
|
|
|
|
static reset() {
|
|
// Skip when no instance was ever created — `state()` would lazily
|
|
// allocate the module-level holder for nothing.
|
|
if (_state === undefined) return;
|
|
_state.lockMap.clear();
|
|
cancelPendingCleanup();
|
|
restoreBodyProperties();
|
|
_state.savedProperties = null;
|
|
_state.cleanupScheduledAt = null;
|
|
_state.idCounter = 0;
|
|
}
|
|
}
|