Dialog scroll lock

This commit is contained in:
Kalle
2026-09-20 12:07:43 +03:00
parent d782e698bc
commit 5906cdeb77
4 changed files with 137 additions and 2 deletions

View File

@@ -120,6 +120,54 @@ describe("SendouDialog", () => {
expect(openDialog().open).toBe(true);
});
test("locks page scrolling while open without changing the page width", async () => {
const tall = document.createElement("div");
tall.style.height = "300vh";
document.body.appendChild(tall);
cleanupFns.push(() => tall.remove());
const root = document.documentElement;
const widthBefore = root.clientWidth;
const screen = await render(
withRouter(
<SendouDialog
heading="Hello"
trigger={<button type="button">Open</button>}
showCloseButton
>
Content
</SendouDialog>,
),
);
await screen.getByRole("button", { name: "Open" }).click();
await expect.element(screen.getByText("Content")).toBeVisible();
await vi.waitFor(() => expect(root.style.overflow).toBe("hidden"));
expect(root.clientWidth).toBe(widthBefore);
await screen.getByRole("button", { name: "Close" }).click();
await vi.waitFor(() => expect(root.style.overflow).toBe(""));
expect(root.style.scrollbarGutter).toBe("");
expect(root.clientWidth).toBe(widthBefore);
});
test("releases the scroll lock when an open dialog unmounts", async () => {
const screen = await render(
withRouter(
<SendouDialog heading="Hello" onClose={() => {}}>
Content
</SendouDialog>,
),
);
await expect.element(screen.getByText("Content")).toBeVisible();
await vi.waitFor(() =>
expect(document.documentElement.style.overflow).toBe("hidden"),
);
await screen.unmount();
expect(document.documentElement.style.overflow).toBe("");
});
test("focuses the dialog itself instead of the close button on open", async () => {
await render(
withRouter(

View File

@@ -67,9 +67,19 @@ function DialogElement({
children,
ref,
}: DialogElementProps) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
useScrollLockWhileOpen(dialogRef);
return (
<dialog
ref={ref}
ref={(dialog) => {
dialogRef.current = dialog;
if (typeof ref === "function") {
ref(dialog);
} else if (ref) {
ref.current = dialog;
}
}}
id={id}
className={clsx(className, {
[styles.blurredBackdrop]: blurredBackdrop,
@@ -100,6 +110,79 @@ function closeOnBackdropClick(event: React.MouseEvent<HTMLDialogElement>) {
}
}
/**
* Locks page scrolling for as long as the dialog is open. Open state is read
* from the DOM (`toggle` events plus the initial `open`) rather than React
* state, so a dialog opened before hydration or closed natively is covered.
*/
function useScrollLockWhileOpen(
dialogRef: React.RefObject<HTMLDialogElement | null>,
) {
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
let release: (() => void) | undefined;
const syncLock = (isOpen: boolean) => {
if (isOpen) {
release ??= lockPageScroll();
} else {
release?.();
release = undefined;
}
};
const onToggle = (event: Event) => {
syncLock((event as ToggleEvent).newState === "open");
};
dialog.addEventListener("toggle", onToggle);
syncLock(dialog.open);
return () => {
dialog.removeEventListener("toggle", onToggle);
syncLock(false);
};
}, [dialogRef]);
}
let pageScrollLocks = 0;
let restorePageScroll: (() => void) | undefined;
/**
* Hides the root scrollbar without the page reflowing into its space: when a
* classic scrollbar was taking up width, `scrollbar-gutter: stable` keeps that
* width reserved. Overlay scrollbars take no width so nothing is reserved.
* Reference counted so nested dialogs release the lock only once all close.
*/
function lockPageScroll() {
if (pageScrollLocks++ === 0) {
const root = document.documentElement;
const scrollbarTakesWidth = window.innerWidth > root.clientWidth;
const previous = {
overflow: root.style.overflow,
scrollbarGutter: root.style.scrollbarGutter,
};
root.style.overflow = "hidden";
if (scrollbarTakesWidth) {
root.style.scrollbarGutter = "stable";
}
restorePageScroll = () => {
root.style.overflow = previous.overflow;
root.style.scrollbarGutter = previous.scrollbarGutter;
};
}
let released = false;
return () => {
if (released) return;
released = true;
if (--pageScrollLocks === 0) {
restorePageScroll?.();
restorePageScroll = undefined;
}
};
}
/** Invoker commands open and close the dialog natively; this guards the JS fallback for browsers without them. */
function supportsInvokerCommands() {
return "commandForElement" in HTMLButtonElement.prototype;

View File

@@ -0,0 +1,4 @@
---
type: bug
---
The page behind an open dialog no longer scrolls, and the layout stays put when the scrollbar is hidden

View File

@@ -251,7 +251,7 @@ export class SendouQMatchPage {
}
/** Waits out the gap the loss confirm keeps between arming and accepting the second tap. */
export async function waitOutLossConfirmMinGap(page: Page) {
async function waitOutLossConfirmMinGap(page: Page) {
// biome-ignore lint/nursery/noPlaywrightWaitForTimeout: the min gap after arming has no observable end
await page.waitForTimeout(LOSS_CONFIRM_MIN_GAP_MS);
}