Files
kandev/apps/web/lib/dialog-body-lock.test.ts
T

111 lines
3.8 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { finishClosingDialogAnimations } from "@kandev/ui/lib/dialog-body-lock";
/**
* Unit coverage for the modal body-lock recovery used by the base Dialog.
*
* Radix releases `pointer-events: none` on <body> when the dialog content
* unmounts, and Presence defers that unmount until the exit animation fires
* `animationend`. When that event never arrives — the document was hidden while
* closing, so animation timelines were frozen, or the dialog unmounted mid-close
* — the lock outlives its dialog and every control on the page stops responding
* with nothing visible to close. These tests pin the two halves of the contract:
* recovery terminates only closing modal animations and leaves Radix to release
* its own body-lock bookkeeping when those modal owners unmount.
*/
const SCROLL_LOCKED = "data-scroll-locked";
const DIALOG_CONTENT = "dialog-content";
function lockBody() {
document.body.style.pointerEvents = "none";
document.body.setAttribute(SCROLL_LOCKED, "1");
}
function isLocked() {
return document.body.style.pointerEvents === "none" || document.body.hasAttribute(SCROLL_LOCKED);
}
function animation() {
return { finish: vi.fn() } as unknown as Animation;
}
/** Mirrors modal/content primitives so selectors see realistic markup. */
function mountContent(
state: "open" | "closed",
slot = DIALOG_CONTENT,
animations: Animation[] = [],
) {
const el = document.createElement("div");
el.setAttribute("role", "dialog");
el.setAttribute("data-slot", slot);
el.setAttribute("data-state", state);
el.getAnimations = () => animations;
document.body.appendChild(el);
return el;
}
beforeEach(() => {
document.body.innerHTML = "";
document.body.removeAttribute(SCROLL_LOCKED);
document.body.style.removeProperty("pointer-events");
});
describe("finishClosingDialogAnimations", () => {
it("finishes a closing dialog animation without mutating its owner's body lock", () => {
const exit = animation();
mountContent("closed", DIALOG_CONTENT, [exit]);
lockBody();
expect(finishClosingDialogAnimations(document)).toBe(true);
expect(exit.finish).toHaveBeenCalledOnce();
expect(isLocked()).toBe(true);
});
it("finishes a closing overlay so an invisible portal cannot intercept clicks", () => {
const exit = animation();
mountContent("closed", "dialog-overlay", [exit]);
expect(finishClosingDialogAnimations(document)).toBe(true);
expect(exit.finish).toHaveBeenCalledOnce();
});
it("ignores open dialog animations", () => {
const open = animation();
mountContent("open", DIALOG_CONTENT, [open]);
lockBody();
expect(finishClosingDialogAnimations(document)).toBe(false);
expect(open.finish).not.toHaveBeenCalled();
expect(isLocked()).toBe(true);
});
it("finishes the closing owner while preserving a second open modal's lock", () => {
const closing = animation();
const open = animation();
mountContent("closed", DIALOG_CONTENT, [closing]);
mountContent("open", "alert-dialog-content", [open]);
lockBody();
expect(finishClosingDialogAnimations(document)).toBe(true);
expect(closing.finish).toHaveBeenCalledOnce();
expect(open.finish).not.toHaveBeenCalled();
expect(isLocked()).toBe(true);
});
it("does not let unrelated open content suppress dialog recovery", () => {
const closing = animation();
mountContent("closed", DIALOG_CONTENT, [closing]);
mountContent("open", "accordion-content");
mountContent("open", "collapsible-content");
lockBody();
expect(finishClosingDialogAnimations(document)).toBe(true);
expect(closing.finish).toHaveBeenCalledOnce();
expect(isLocked()).toBe(true);
});
it("reports nothing to do without a closing dialog animation", () => {
expect(finishClosingDialogAnimations(document)).toBe(false);
});
});