melt
Version:
The next generation of Melt UI. Built for Svelte 5.
183 lines (182 loc) • 6.81 kB
JavaScript
import { isIOS, isWebKit } from "./platform";
function isOverflowElement(element) {
const overflow = getComputedStyle(element).overflow;
return overflow !== "visible";
}
function hasInsetScrollbars() {
if (typeof document === "undefined") {
return false;
}
const win = window;
return win.innerWidth - document.documentElement.clientWidth > 0;
}
function preventScrollOverlayScrollbars() {
const html = document.documentElement;
const body = document.body;
const elementToLock = isOverflowElement(html) ? html : body;
const originalOverflow = elementToLock.style.overflow;
elementToLock.style.overflow = "hidden";
return () => {
elementToLock.style.overflow = originalOverflow;
};
}
function preventScrollInsetScrollbars() {
const html = document.documentElement;
const body = document.body;
const win = window;
let scrollTop = 0;
let scrollLeft = 0;
let rafId = null;
const supportsStableScrollbarGutter = typeof CSS !== "undefined" && CSS.supports?.("scrollbar-gutter", "stable");
if (isWebKit() && (win.visualViewport?.scale ?? 1) !== 1) {
return () => { };
}
let originalHtmlStyles = {};
let originalBodyStyles = {};
let originalHtmlScrollBehavior = "";
function lockScroll() {
const htmlStyles = win.getComputedStyle(html);
const bodyStyles = win.getComputedStyle(body);
const htmlScrollbarGutterValue = htmlStyles.scrollbarGutter || "";
const hasBothEdges = htmlScrollbarGutterValue.includes("both-edges");
const scrollbarGutterValue = hasBothEdges ? "stable both-edges" : "stable";
scrollTop = html.scrollTop;
scrollLeft = html.scrollLeft;
originalHtmlStyles = {
scrollbarGutter: html.style.scrollbarGutter,
overflowY: html.style.overflowY,
overflowX: html.style.overflowX,
};
originalHtmlScrollBehavior = html.style.scrollBehavior;
originalBodyStyles = {
position: body.style.position,
height: body.style.height,
width: body.style.width,
boxSizing: body.style.boxSizing,
overflowY: body.style.overflowY,
overflowX: body.style.overflowX,
scrollBehavior: body.style.scrollBehavior,
};
const isScrollableY = html.scrollHeight > html.clientHeight;
const isScrollableX = html.scrollWidth > html.clientWidth;
const hasConstantOverflowY = htmlStyles.overflowY === "scroll" || bodyStyles.overflowY === "scroll";
const hasConstantOverflowX = htmlStyles.overflowX === "scroll" || bodyStyles.overflowX === "scroll";
const scrollbarWidth = Math.max(0, win.innerWidth - html.clientWidth);
const scrollbarHeight = Math.max(0, win.innerHeight - html.clientHeight);
const marginY = parseFloat(bodyStyles.marginTop) + parseFloat(bodyStyles.marginBottom);
const marginX = parseFloat(bodyStyles.marginLeft) + parseFloat(bodyStyles.marginRight);
const elementToLock = isOverflowElement(html) ? html : body;
if (supportsStableScrollbarGutter) {
html.style.scrollbarGutter = scrollbarGutterValue;
elementToLock.style.overflowY = "hidden";
elementToLock.style.overflowX = "hidden";
return;
}
Object.assign(html.style, {
scrollbarGutter: scrollbarGutterValue,
overflowY: "hidden",
overflowX: "hidden",
});
if (isScrollableY || hasConstantOverflowY) {
html.style.overflowY = "scroll";
}
if (isScrollableX || hasConstantOverflowX) {
html.style.overflowX = "scroll";
}
Object.assign(body.style, {
position: "relative",
height: marginY || scrollbarHeight ? `calc(100dvh - ${marginY + scrollbarHeight}px)` : "100dvh",
width: marginX || scrollbarWidth ? `calc(100vw - ${marginX + scrollbarWidth}px)` : "100vw",
boxSizing: "border-box",
overflow: "hidden",
scrollBehavior: "unset",
});
body.scrollTop = scrollTop;
body.scrollLeft = scrollLeft;
html.setAttribute("data-melt-scroll-locked", "");
html.style.scrollBehavior = "unset";
}
function cleanup() {
Object.assign(html.style, originalHtmlStyles);
Object.assign(body.style, originalBodyStyles);
if (!supportsStableScrollbarGutter) {
html.scrollTop = scrollTop;
html.scrollLeft = scrollLeft;
html.removeAttribute("data-melt-scroll-locked");
html.style.scrollBehavior = originalHtmlScrollBehavior;
}
}
function handleResize() {
cleanup();
rafId = requestAnimationFrame(lockScroll);
}
lockScroll();
win.addEventListener("resize", handleResize);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
cleanup();
win.removeEventListener("resize", handleResize);
};
}
class ScrollLocker {
lockCount = 0;
restore = null;
timeoutLockId = null;
timeoutUnlockId = null;
acquire() {
this.lockCount += 1;
if (this.lockCount === 1 && this.restore === null) {
this.timeoutLockId = setTimeout(() => this.lock(), 0);
}
return this.release;
}
release = () => {
this.lockCount -= 1;
if (this.lockCount === 0 && this.restore) {
this.timeoutUnlockId = setTimeout(this.unlock, 0);
}
};
unlock = () => {
if (this.lockCount === 0 && this.restore) {
this.restore?.();
this.restore = null;
}
};
lock() {
if (this.lockCount === 0 || this.restore !== null) {
return;
}
const html = document.documentElement;
const htmlOverflowY = window.getComputedStyle(html).overflowY;
if (htmlOverflowY === "hidden" || htmlOverflowY === "clip") {
this.restore = () => { };
return;
}
const hasOverlayScrollbars = isIOS() || !hasInsetScrollbars();
this.restore = hasOverlayScrollbars
? preventScrollOverlayScrollbars()
: preventScrollInsetScrollbars();
}
}
const SCROLL_LOCKER = new ScrollLocker();
/**
* Locks the scroll of the document when enabled.
*
* @param enabled - Whether to enable the scroll lock.
*/
export function useScrollLock(enabled = true) {
let releaseFn = null;
const cleanup = () => {
releaseFn?.();
releaseFn = null;
};
$effect(() => {
if (!enabled)
return;
releaseFn = SCROLL_LOCKER.acquire();
return cleanup;
});
return cleanup;
}