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.
svelte-kit-vice/src/arts/adom/body-scroll-lock.svelte.ts

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;
}
}

Powered by TurnKey Linux.