fix: lock background scroll while a Modal or Sheet is open

Overlays didn't lock body scroll, so the page behind a modal/sheet scrolled
under it. Add a ref-counted scroll lock (so stacked overlays don't prematurely
release it) and hold it via an effect in Modal and Sheet while open, restoring
on close or unmount. Focus-trapping was already handled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 15:23:07 +02:00
parent 3b783c1d9b
commit ba3e5b481b
6 changed files with 141 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { lockBodyScroll, unlockBodyScroll } from '$lib/scroll-lock';
import X from '@lucide/svelte/icons/x';
let {
@@ -46,6 +47,14 @@
}
});
// Lock background scroll while open so the page behind the modal can't
// scroll under it; released on close or unmount.
$effect(() => {
if (!open) return;
lockBodyScroll();
return () => unlockBodyScroll();
});
function focusable(): HTMLElement[] {
if (!dialog) return [];
// Standard set of "tab can land here" elements, minus those

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { lockBodyScroll, unlockBodyScroll } from '$lib/scroll-lock';
import X from '@lucide/svelte/icons/x';
let {
@@ -32,6 +33,13 @@
}
});
// Lock background scroll while the sheet is open (released on close/unmount).
$effect(() => {
if (!open) return;
lockBodyScroll();
return () => unlockBodyScroll();
});
function focusable(): HTMLElement[] {
if (!panel) return [];
const selector = [

View File

@@ -0,0 +1,36 @@
import { describe, it, expect, afterEach } from 'vitest';
import { lockBodyScroll, unlockBodyScroll, _lockCount } from './scroll-lock';
describe('scroll-lock', () => {
afterEach(() => {
// Drain any leftover locks so tests are independent.
while (_lockCount() > 0) unlockBodyScroll();
document.body.style.overflow = '';
});
it('locks body scroll on first lock and restores on last unlock', () => {
document.body.style.overflow = 'auto';
lockBodyScroll();
expect(document.body.style.overflow).toBe('hidden');
unlockBodyScroll();
// Restores the pre-lock value.
expect(document.body.style.overflow).toBe('auto');
expect(_lockCount()).toBe(0);
});
it('is ref-counted: stacked overlays keep the lock until all release', () => {
lockBodyScroll();
lockBodyScroll();
expect(_lockCount()).toBe(2);
unlockBodyScroll();
// Still locked — one overlay remains open.
expect(document.body.style.overflow).toBe('hidden');
unlockBodyScroll();
expect(document.body.style.overflow).toBe('');
});
it('ignores an unbalanced unlock', () => {
unlockBodyScroll();
expect(_lockCount()).toBe(0);
});
});

View File

@@ -0,0 +1,31 @@
// Ref-counted body scroll lock shared by overlay components (Modal, Sheet).
// A counter keeps the lock held while any overlay is open, so closing one of
// two stacked overlays doesn't prematurely restore background scrolling.
let count = 0;
let previousOverflow = '';
/** Lock body scroll (idempotent per caller — pair every call with `unlock`). */
export function lockBodyScroll(): void {
if (typeof document === 'undefined') return;
if (count === 0) {
previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
count += 1;
}
/** Release one lock; body scroll is restored once the last one is released. */
export function unlockBodyScroll(): void {
if (typeof document === 'undefined') return;
if (count === 0) return;
count -= 1;
if (count === 0) {
document.body.style.overflow = previousOverflow;
}
}
/** Test-only: current lock depth. */
export function _lockCount(): number {
return count;
}