Files
siftlode/frontend/src/components/ui/Modal.purpose.dom.test.tsx
T
peter 4191189b4c fix(ui): a late form takes a dialog's focus once, even if it arrived elsewhere
The watch stayed armed when the form arrived while the focus was off the close button, so a
later change in the body pulled the focus off a close button the user went back to. It now
disarms on arrival and re-arms only when the focus is taken away.
2026-10-04 04:16:52 +02:00

232 lines
8.3 KiB
TypeScript

// @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<typeof import("react-i18next")>();
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<string, unknown>).offsetParent;
});
// A body that owns its footer, one component down — the issue composer's shape.
function Form() {
return (
<div>
<input aria-label="name" />
<input aria-label="channel" />
<ModalFooter>
<button>cancel</button>
<button>save</button>
</ModalFooter>
</div>
);
}
describe("Modal initial focus by purpose (C4)", () => {
it("field: the body's first control", () => {
render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Form />
</Modal>,
);
expect(document.activeElement).toBe(screen.getByLabelText("name"));
});
it("field with nothing to fill in: the ✕", () => {
render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<p>nothing here</p>
</Modal>,
);
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 ? <input aria-label="title" /> : <p>loading…</p>;
}
it("field that loads late: the focus moves to it when it arrives", async () => {
const { rerender } = render(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready={false} />
</Modal>,
);
expect(document.activeElement).toBe(closeBtn());
rerender(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready />
</Modal>,
);
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(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready={false} />
<ModalFooter>
<button>cancel</button>
</ModalFooter>
</Modal>,
);
const cancel = screen.getByRole("button", { name: "cancel" });
act(() => cancel.focus());
rerender(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready />
<ModalFooter>
<button>cancel</button>
</ModalFooter>
</Modal>,
);
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) => (
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Late ready={ready} />
{hint && <p>hint</p>}
<ModalFooter>
<button>cancel</button>
</ModalFooter>
</Modal>
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Form />
</Modal>,
);
expect(document.activeElement).toBe(closeBtn());
});
it("action: the footer's autoFocus button (a confirm's least destructive)", () => {
render(
<Modal initialFocus="action" title="t" onClose={() => {}}>
<p>Delete it?</p>
<ModalFooter>
<button autoFocus>cancel</button>
<button>delete</button>
</ModalFooter>
</Modal>,
);
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(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Form />
</Modal>,
);
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(
<Modal initialFocus="field" title="t" onClose={() => {}}>
<Form />
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<p>read me</p>
</Modal>,
);
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;/);
}
});
});