// @vitest-environment jsdom // // C4 (DESIGN, Dialogs): a dialog opens focused by its PURPOSE, and its commit row is a footer pinned // outside the scrolling body. offsetParent is stubbed "visible" as in Modal.dom.test.tsx (jsdom never // lays out, and the focus rules skip unrendered controls). import { readFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, render, screen } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); vi.mock("../../lib/history", () => ({ useBackToClose: () => {} })); import Modal, { ModalFooter } from "./Modal"; const closeBtn = () => screen.getByRole("button", { name: "common.close" }); const press = (key: string, shiftKey = false) => act(() => { window.dispatchEvent( new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }), ); }); let origDesc: PropertyDescriptor | undefined; beforeEach(() => { origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent"); Object.defineProperty(HTMLElement.prototype, "offsetParent", { configurable: true, get(this: HTMLElement) { return this.isConnected ? document.body : null; }, }); }); afterEach(() => { cleanup(); if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc); else delete (HTMLElement.prototype as unknown as Record).offsetParent; }); // A body that owns its footer, one component down — the issue composer's shape. function Form() { return (
); } describe("Modal initial focus by purpose (C4)", () => { it("field: the body's first control", () => { render( {}}>
, ); expect(document.activeElement).toBe(screen.getByLabelText("name")); }); it("field with nothing to fill in: the ✕", () => { render( {}}>

nothing here

, ); expect(document.activeElement).toBe(closeBtn()); }); // The issue composer's shape: a spinner first, the form when its query lands. function Late({ ready }: { ready: boolean }) { return ready ? :

loading…

; } it("field that loads late: the focus moves to it when it arrives", async () => { const { rerender } = render( {}}> , ); expect(document.activeElement).toBe(closeBtn()); rerender( {}}> , ); await act(async () => {}); expect(document.activeElement).toBe(screen.getByLabelText("title")); }); it("field that loads late: a focus the user has moved stays put", async () => { const { rerender } = render( {}}> , ); const cancel = screen.getByRole("button", { name: "cancel" }); act(() => cancel.focus()); rerender( {}}> , ); await act(async () => {}); expect(document.activeElement).toBe(cancel); }); // …and it stays the user's: the form disarms the watch when it arrives, so a later change in the // body (a hint appearing as they type) cannot pull the focus off a ✕ they went back to. it("field that loads late: once arrived, it never takes the focus off the ✕ again", async () => { const view = (ready: boolean, hint = false) => ( {}}> {hint &&

hint

}
); const { rerender } = render(view(false)); act(() => screen.getByRole("button", { name: "cancel" }).focus()); rerender(view(true)); await act(async () => {}); act(() => closeBtn().focus()); rerender(view(true, true)); await act(async () => {}); expect(document.activeElement).toBe(closeBtn()); }); it("close: the ✕, even with a field in the body", () => { render( {}}> , ); expect(document.activeElement).toBe(closeBtn()); }); it("action: the footer's autoFocus button (a confirm's least destructive)", () => { render( {}}>

Delete it?

, ); expect(document.activeElement).toBe(screen.getByRole("button", { name: "cancel" })); }); }); describe("Modal footer (C4)", () => { it("renders outside the scrolling body, inside the dialog, after it", () => { render( {}}> , ); const save = screen.getByRole("button", { name: "save" }); const body = screen.getByLabelText("name").closest(".panel-scroller"); expect(body).not.toBeNull(); expect(body!.contains(save)).toBe(false); expect(screen.getByRole("dialog").contains(save)).toBe(true); expect(body!.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); // The trap only acts at its ends (a middle Tab is the browser's, which jsdom does not move): the // footer's last button IS the dialog's last stop, both ways. it("the Tab trap counts the footer: its last button wraps to the ✕ and back", () => { render( {}}> , ); const save = screen.getByRole("button", { name: "save" }); act(() => save.focus()); press("Tab"); expect(document.activeElement).toBe(closeBtn()); press("Tab", true); expect(document.activeElement).toBe(save); }); it("no footer: the slot stays empty (and `empty:hidden` takes it out)", () => { render( {}}>

read me

, ); const slot = document.querySelector(".fade-above-panel"); expect(slot).not.toBeNull(); expect(slot!.childElementCount).toBe(0); expect(slot!.className).toContain("empty:hidden"); }); }); // The fades live in CSS, which jsdom cannot evaluate: the pairing is held here instead. Header and // footer sit OUTSIDE the scroller, so they reach its timeline only through the panel's scope. describe("the panel fades follow the body's scroll (C4 + B9)", () => { const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8"); const css = read("../../index.css"); const modal = read("./Modal.tsx"); it("the Modal wires the scope, the scroller and both fades", () => { expect(modal).toMatch(/const PANEL = "[^"]*\bpanel-scroll-scope\b/); expect(modal).toMatch(/className="[^"]*\bpanel-scroller\b[^"]*"/); expect(modal).toMatch(/className="[^"]*\bfade-below-panel\b[^"]*"/); expect(modal).toMatch(/className="[^"]*\bfade-above-panel\b[^"]*"/); }); it("index.css names the timeline and drives both fades from it, off at rest", () => { expect(css).toMatch(/@utility panel-scroll-scope \{\s*timeline-scope: --panel-scroll;/); expect(css).toMatch(/@utility panel-scroller \{\s*scroll-timeline: --panel-scroll block;/); for (const util of ["fade-below-panel", "fade-above-panel"]) { const block = css.slice(css.indexOf(`@utility ${util} {`)).split(/\n}\n/)[0]; expect(block, util).toMatch(/opacity: 0;/); expect(block, util).toMatch(/animation-timeline: --panel-scroll;/); } }); });