mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-25 04:37:13 -05:00
Dialog scroll lock
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
changelog/2026-09-20-dialog-scroll-lock.md
Normal file
4
changelog/2026-09-20-dialog-scroll-lock.md
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user