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.
232 lines
8.3 KiB
TypeScript
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;/);
|
|
}
|
|
});
|
|
});
|