feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that loads late takes the focus while it still rests on the close button, and ModalFooter portals a dialog's commit row under the scrolling body from any depth. The header and footer fades follow the body's scroll timeline, and the viewport's interactive-widget=resizes-content keeps the footer above an on-screen keyboard.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// @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);
|
||||
});
|
||||
|
||||
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;/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user