From 18f4869b208c5f02148d162369a19f2628c45e0d Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 04:41:35 +0700 Subject: [PATCH] Scroll lock utility --- .../scroll-lock/create-scroll-lock.ts | 37 +++++++++++++++ .../core/src/utilities/scroll-lock/index.ts | 2 + .../core/tests/utilities/scroll-lock.test.ts | 45 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 packages/core/src/utilities/scroll-lock/create-scroll-lock.ts create mode 100644 packages/core/src/utilities/scroll-lock/index.ts create mode 100644 packages/core/tests/utilities/scroll-lock.test.ts diff --git a/packages/core/src/utilities/scroll-lock/create-scroll-lock.ts b/packages/core/src/utilities/scroll-lock/create-scroll-lock.ts new file mode 100644 index 0000000..2c13abb --- /dev/null +++ b/packages/core/src/utilities/scroll-lock/create-scroll-lock.ts @@ -0,0 +1,37 @@ +// Global lock counter so nested modals don't prematurely restore scroll +let lockCount = 0; +let originalOverflow = ""; + +export interface ScrollLock { + lock: () => void; + unlock: () => void; +} + +/** + * Prevents body scroll. Uses a reference count so nested overlays + * (e.g. a dialog inside a drawer) don't prematurely restore scrolling. + */ +export function createScrollLock(): ScrollLock { + let locked = false; + + return { + lock() { + if (locked) return; + locked = true; + if (lockCount === 0) { + originalOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } + lockCount++; + }, + unlock() { + if (!locked) return; + locked = false; + lockCount = Math.max(0, lockCount - 1); + if (lockCount === 0) { + document.body.style.overflow = originalOverflow; + originalOverflow = ""; + } + }, + }; +} diff --git a/packages/core/src/utilities/scroll-lock/index.ts b/packages/core/src/utilities/scroll-lock/index.ts new file mode 100644 index 0000000..e29812b --- /dev/null +++ b/packages/core/src/utilities/scroll-lock/index.ts @@ -0,0 +1,2 @@ +export { createScrollLock } from "./create-scroll-lock"; +export type { ScrollLock } from "./create-scroll-lock"; diff --git a/packages/core/tests/utilities/scroll-lock.test.ts b/packages/core/tests/utilities/scroll-lock.test.ts new file mode 100644 index 0000000..5a884c8 --- /dev/null +++ b/packages/core/tests/utilities/scroll-lock.test.ts @@ -0,0 +1,45 @@ +import { createRoot } from "solid-js"; +import { afterEach, describe, expect, it } from "vitest"; +import { createScrollLock } from "../../src/utilities/scroll-lock/create-scroll-lock"; + +describe("createScrollLock", () => { + afterEach(() => { + document.body.style.overflow = ""; + document.body.style.paddingRight = ""; + }); + + it("sets overflow hidden on body when locked", () => { + createRoot((dispose) => { + const lock = createScrollLock(); + lock.lock(); + expect(document.body.style.overflow).toBe("hidden"); + lock.unlock(); + dispose(); + }); + }); + + it("restores overflow on unlock", () => { + document.body.style.overflow = "auto"; + createRoot((dispose) => { + const lock = createScrollLock(); + lock.lock(); + lock.unlock(); + expect(document.body.style.overflow).toBe("auto"); + dispose(); + }); + }); + + it("handles multiple locks — only unlocks when all release", () => { + createRoot((dispose) => { + const lockA = createScrollLock(); + const lockB = createScrollLock(); + lockA.lock(); + lockB.lock(); + lockA.unlock(); + expect(document.body.style.overflow).toBe("hidden"); // still locked by B + lockB.unlock(); + expect(document.body.style.overflow).toBe(""); // now unlocked + dispose(); + }); + }); +});