Files
tools/vue/toolkit/src/composables/browser/useScrollLock/index.ts
T
robonen c7644ade69 fix(vue): eslint/tsconfig migration + resolve type errors
@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig;
fix composable + test type errors (writable computed returns, null guards,
overload-compatible signatures, typed test helpers) — all type-level.
2026-06-07 16:29:39 +07:00

184 lines
5.6 KiB
TypeScript

import type { WritableComputedRef } from 'vue';
import { computed, shallowRef, toRef, watch } from 'vue';
import type { VoidFunction } from '@robonen/stdlib';
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
import { defaultNavigator, defaultWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
type LockableElement = HTMLElement | SVGElement;
export interface UseScrollLockOptions extends ConfigurableWindow, ConfigurableNavigator {}
export type UseScrollLockReturn = WritableComputedRef<boolean>;
/**
* Stores each element's `style.overflow` value as it was the first time we
* touched it, so unlocking restores exactly what the author had set (rather
* than wiping inline overflow entirely). A WeakMap keeps this from pinning
* detached elements in memory.
*/
const elementInitialOverflow = /* #__PURE__ */ new WeakMap<LockableElement, string>();
function isIOS(navigator: Navigator | undefined): boolean {
if (!navigator)
return false;
const ua = navigator.userAgent;
// iPhone / iPod / legacy iPad, plus iPadOS 13+ which masquerades as MacIntel
// while reporting a touch screen.
return /iP(?:ad|hone|od)/.test(ua)
|| (navigator.platform === 'MacIntel' && (navigator.maxTouchPoints ?? 0) > 1);
}
/**
* Walks up from the touched node looking for an ancestor that can actually
* scroll. If one exists we must NOT prevent the touchmove, otherwise nested
* scroll regions (e.g. a modal body) become un-scrollable on iOS.
*/
function checkOverflowScroll(element: Element, window: Window): boolean {
const style = window.getComputedStyle(element);
if (
style.overflowX === 'scroll'
|| style.overflowY === 'scroll'
|| (style.overflowX === 'auto' && element.clientWidth < element.scrollWidth)
|| (style.overflowY === 'auto' && element.clientHeight < element.scrollHeight)
) {
return true;
}
const parent = element.parentNode as Element | null;
if (!parent || parent.tagName === 'BODY')
return false;
return checkOverflowScroll(parent, window);
}
function preventDefault(event: TouchEvent, window: Window): void {
const target = event.target as Element | null;
// Allow scrolling inside genuinely scrollable descendants.
if (target && checkOverflowScroll(target, window))
return;
// A multi-touch gesture (pinch-zoom) should be left alone.
if (event.touches.length > 1)
return;
if (event.cancelable)
event.preventDefault();
}
/**
* @name useScrollLock
* @category Browser
* @description Lock scrolling of an element by toggling `overflow: hidden`,
* preserving the element's prior inline overflow and handling iOS `touchmove`.
* Returns a writable boolean ref — set it to lock/unlock, read it for state.
*
* @param {MaybeComputedElementRef} element - The element (or template ref / getter) to lock.
* @param {boolean} [initialState] - Whether the element starts locked. Defaults to `false`.
* @param {UseScrollLockOptions} [options] - Configurable `window` / `navigator` (mainly for SSR & testing).
* @returns {UseScrollLockReturn} A writable boolean ref; `true` while locked.
*
* @example
* const el = useTemplateRef<HTMLElement>('el');
* const isLocked = useScrollLock(el);
* isLocked.value = true; // lock
* isLocked.value = false; // unlock
*
* @since 0.0.15
*/
export function useScrollLock(
element: MaybeComputedElementRef,
initialState = false,
options: UseScrollLockOptions = {},
): UseScrollLockReturn {
const { window = defaultWindow, navigator = defaultNavigator } = options;
const isLocked = shallowRef(initialState);
let stopTouchMoveListener: VoidFunction | null = null;
let initialOverflow = '';
watch(
toRef(element),
() => {
const el = unrefElement(element) as LockableElement | undefined;
if (!el)
return;
const style = el.style;
if (!elementInitialOverflow.has(el))
elementInitialOverflow.set(el, style.overflow);
if (style.overflow !== 'hidden')
initialOverflow = style.overflow;
// The element was already hidden before we attached — treat as locked.
if (style.overflow === 'hidden') {
isLocked.value = true;
return;
}
if (isLocked.value)
style.overflow = 'hidden';
},
{ immediate: true },
);
const lock = (): void => {
const el = unrefElement(element) as LockableElement | undefined;
if (!el || isLocked.value)
return;
if (window && isIOS(navigator)) {
stopTouchMoveListener = useEventListener(
el as HTMLElement,
'touchmove',
(event: Event) => preventDefault(event as TouchEvent, window),
{ passive: false },
);
}
el.style.overflow = 'hidden';
isLocked.value = true;
};
const unlock = (): void => {
const el = unrefElement(element) as LockableElement | undefined;
if (!el || !isLocked.value)
return;
stopTouchMoveListener?.();
stopTouchMoveListener = null;
el.style.overflow = initialOverflow;
elementInitialOverflow.delete(el);
isLocked.value = false;
};
// Restore overflow and detach the iOS touchmove listener when the owning
// scope is disposed, regardless of where the lock was triggered from.
tryOnScopeDispose(unlock);
return computed<boolean>({
get() {
return isLocked.value;
},
set(value) {
if (value)
lock();
else
unlock();
},
});
}