@wb-tech/ui-react
Version:
WildBerries react ui-kit
423 lines (331 loc) • 13.8 kB
text/typescript
export interface IBodyScrollOptions {
reserveScrollBarGap?: boolean | undefined,
allowTouchMove?: ((el: EventTarget) => boolean) | undefined
}
interface IBodyStyleType {
position: string,
top: string,
left: string,
width: string,
height: string,
overflow: string
}
interface ILock {
targetElement: HTMLElement,
options: IBodyScrollOptions
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const isIosDevice = window?.navigator?.platform && (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
/iP(ad|hone|od)/.test(window?.navigator?.platform) || window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1
);
export function useBodyScrollLock() {
// Older browsers don't support event options, feature detect it.
let hasPassiveEvents = false;
if(typeof window !== 'undefined') {
const passiveTestOptions: { passive: undefined } = {
get passive() {
hasPassiveEvents = true;
return undefined;
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).addEventListener('testPassive', null, passiveTestOptions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).removeEventListener('testPassive', null, passiveTestOptions);
}
type THandleScrollEvent = TouchEvent;
let locks: Array<ILock> = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const locksIndex = new Map<any, number>();
let documentListenerAdded = false;
let initialClientY = -1;
let previousBodyOverflowSetting: string | undefined;
let htmlStyle:
| {
height: string,
overflow: string
}
| undefined;
let bodyStyle: IBodyStyleType | undefined;
let previousBodyPaddingRight: string | undefined;
// returns true if `el` should be allowed to receive touchmove events.
const allowTouchMove = (el: EventTarget): boolean => locks.some((lock) => {
return !!(lock.options.allowTouchMove && lock.options.allowTouchMove(el));
});
const preventDefault = (rawEvent: THandleScrollEvent): boolean => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unnecessary-condition
const e: any = rawEvent || window.event;
// For the case whereby consumers adds a touchmove event listener to document.
// Recall that we do document.addEventListener('touchmove', preventDefault, { passive: false })
// in disableBodyScroll - so if we provide this opportunity to allowTouchMove, then
// the touchmove event on document will break.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if(allowTouchMove(e?.target)) {
return true;
}
// Do not prevent if the event has more than one touch (usually meaning this is a multi touch gesture like pinch to zoom).
if(e.touches.length > 1) {
return true;
}
if(!isIosDevice && e.preventDefault) {
e.preventDefault();
}
return false;
};
const setOverflowHidden = (options?: IBodyScrollOptions) => {
// If previousBodyPaddingRight is already set, don't set it again.
if(previousBodyPaddingRight === undefined) {
const reserveScrollBarGap = !!options && options.reserveScrollBarGap === true;
const scrollBarGap = window.innerWidth - document.documentElement.getBoundingClientRect().width;
if(reserveScrollBarGap && scrollBarGap > 0) {
const computedBodyPaddingRight = parseInt(
window
.getComputedStyle(document.body)
.getPropertyValue('padding-right'),
10
);
previousBodyPaddingRight = document.body.style.paddingRight;
document.body.style.paddingRight = `${
computedBodyPaddingRight + scrollBarGap
}px`;
}
}
// If previousBodyOverflowSetting is already set, don't set it again.
if(previousBodyOverflowSetting === undefined) {
previousBodyOverflowSetting = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
};
const restoreOverflowSetting = () => {
if(previousBodyPaddingRight !== undefined) {
document.body.style.paddingRight = previousBodyPaddingRight;
// Restore previousBodyPaddingRight to undefined so setOverflowHidden knows it
// can be set again.
previousBodyPaddingRight = undefined;
}
if(previousBodyOverflowSetting !== undefined) {
document.body.style.overflow = previousBodyOverflowSetting;
// Restore previousBodyOverflowSetting to undefined
// so setOverflowHidden knows it can be set again.
previousBodyOverflowSetting = undefined;
}
};
const setPositionFixed = () => window.requestAnimationFrame(() => {
const $html = document.documentElement;
const $body = document.body;
// If bodyStyle is already set, don't set it again.
if(bodyStyle === undefined) {
htmlStyle = { ...$html.style };
bodyStyle = { ...$body.style };
// Update the dom inside an animation frame
const { scrollY, scrollX, innerHeight } = window;
$html.style.height = '100%';
$html.style.overflow = 'hidden';
$body.style.position = 'fixed';
$body.style.top = `${-scrollY}px`;
$body.style.left = `${-scrollX}px`;
$body.style.width = '100%';
$body.style.height = 'auto';
$body.style.overflow = 'hidden';
setTimeout(
() => window.requestAnimationFrame(() => {
// Attempt to check if the bottom bar appeared due to the position change
const bottomBarHeight = innerHeight - window.innerHeight;
if(bottomBarHeight && scrollY >= innerHeight) {
// Move the content further up so that the bottom bar doesn't hide it
$body.style.top = `${-(scrollY + bottomBarHeight)}px`;
}
}),
300
);
}
});
const restorePositionSetting = () => {
if(bodyStyle !== undefined) {
// Convert the position from "px" to Int
const y = -parseInt(document.body.style.top, 10);
const x = -parseInt(document.body.style.left, 10);
// Restore styles
const $html = document.documentElement;
const $body = document.body;
$html.style.height = htmlStyle?.height ?? '';
$html.style.overflow = htmlStyle?.overflow ?? '';
$body.style.position = bodyStyle.position || '';
$body.style.top = bodyStyle.top || '';
$body.style.left = bodyStyle.left || '';
$body.style.width = bodyStyle.width || '';
$body.style.height = bodyStyle.height || '';
$body.style.overflow = bodyStyle.overflow || '';
// Restore scroll
window.scrollTo(x, y);
bodyStyle = undefined;
}
};
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions
const isTargetElementTotallyScrolled = (targetElement: HTMLElement | undefined): boolean => {
if(targetElement) {
return targetElement.scrollHeight - targetElement.scrollTop <= targetElement.clientHeight;
}
return false;
};
const handleScroll = (
event: THandleScrollEvent,
targetElement: HTMLElement
): boolean => {
const clientY = event.targetTouches[0].clientY - initialClientY;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if(allowTouchMove(event.target!)) {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if(targetElement && targetElement.scrollTop === 0 && clientY > 0) {
// element is at the top of its scroll.
return preventDefault(event);
}
if(isTargetElementTotallyScrolled(targetElement) && clientY < 0) {
// element is at the bottom of its scroll.
return preventDefault(event);
}
event.stopPropagation();
return true;
};
/**
*
* @param targetElement HTMLElement
* @param options BodyScrollOptions
* @returns void
*/
const disableBodyScroll = (
targetElement: HTMLElement,
options?: IBodyScrollOptions
): void => {
// targetElement must be provided
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if(!targetElement) {
console.error(
'disableBodyScroll unsuccessful - targetElement must be provided when calling disableBodyScroll on IOS devices.'
);
return;
}
locksIndex.set(
targetElement,
locksIndex.get(targetElement) ? locksIndex.get(targetElement)! + 1 : 1
);
// disableBodyScroll must not have been called on this targetElement before
if(locks.some((lock) => lock.targetElement === targetElement)) {
return;
}
const lock = {
targetElement,
options: options ?? {}
};
locks = [...locks, lock];
if(isIosDevice) {
setPositionFixed();
} else {
setOverflowHidden(options);
}
if(isIosDevice) {
// eslint-disable-next-line no-param-reassign
targetElement.ontouchstart = (event: THandleScrollEvent) => {
if(event.targetTouches.length === 1) {
// detect single touch.
initialClientY = event.targetTouches[0].clientY;
}
};
// eslint-disable-next-line no-param-reassign
targetElement.ontouchmove = (event: THandleScrollEvent) => {
if(event.targetTouches.length === 1) {
// detect single touch.
handleScroll(event, targetElement);
}
};
if(!documentListenerAdded) {
document.addEventListener(
'touchmove',
preventDefault,
hasPassiveEvents ? { passive: false } : undefined
);
documentListenerAdded = true;
}
}
};
const clearAllBodyScrollLocks = (): void => {
if(isIosDevice) {
// Clear all locks ontouchstart/ontouchmove handlers, and the references.
locks.forEach((lock: ILock) => {
// eslint-disable-next-line no-param-reassign
lock.targetElement.ontouchstart = null;
// eslint-disable-next-line no-param-reassign
lock.targetElement.ontouchmove = null;
});
if(documentListenerAdded) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(document as any).removeEventListener(
'touchmove',
preventDefault,
hasPassiveEvents ? { passive: false } : undefined
);
documentListenerAdded = false;
}
// Reset initial clientY.
initialClientY = -1;
}
if(isIosDevice) {
restorePositionSetting();
} else {
restoreOverflowSetting();
}
locks = [];
locksIndex.clear();
};
/**
* @param targetElement
* @returns void
*/
const enableBodyScroll = (targetElement: HTMLElement): void => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if(!targetElement) {
console.error(
'enableBodyScroll unsuccessful - targetElement must be provided when calling enableBodyScroll on IOS devices.'
);
return;
}
locksIndex.set(
targetElement,
locksIndex.get(targetElement) ? locksIndex.get(targetElement)! - 1 : 0
);
if(locksIndex.get(targetElement) === 0) {
locks = locks.filter((lock) => lock.targetElement !== targetElement);
locksIndex.delete(targetElement);
}
if(isIosDevice) {
// eslint-disable-next-line no-param-reassign
targetElement.ontouchstart = null;
// eslint-disable-next-line no-param-reassign
targetElement.ontouchmove = null;
if(documentListenerAdded && locks.length === 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(document as any).removeEventListener(
'touchmove',
preventDefault,
hasPassiveEvents ? { passive: false } : undefined
);
documentListenerAdded = false;
}
}
if(locks.length === 0) {
if(isIosDevice) {
restorePositionSetting();
} else {
restoreOverflowSetting();
}
}
};
return {
enableBodyScroll,
disableBodyScroll,
clearAllBodyScrollLocks
};
}