refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Modal: focus management (FE-9). This was DEFERRED from the E4 deep-review because it's
|
||||
// jsdom-untestable as written — `focusablesIn` filters on `el.offsetParent !== null`, and jsdom
|
||||
// returns null for EVERY element (it never lays out), so the trap would see zero focusables and do
|
||||
// nothing. We stub `HTMLElement.prototype.offsetParent` to report "visible" (non-null) for any
|
||||
// connected node, which is exactly what a real browser reports for the shown dialog controls; the
|
||||
// separate no-stub describe below documents the raw-jsdom reality (empty → the guard no-ops).
|
||||
//
|
||||
// The overlay/history machinery (useBackToClose) is exercised in history.dom.test.ts, so it's mocked
|
||||
// out here to isolate the focus-trap behaviour from its module-global history side-effects.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, act, cleanup } 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 }) };
|
||||
});
|
||||
// Isolate the focus trap from the Back-to-close history stack (tested in history.dom.test.ts).
|
||||
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||
|
||||
import Modal from "./Modal";
|
||||
|
||||
// Fire a keydown on window (where Modal installs its handler), inside act.
|
||||
const press = (key: string, shiftKey = false) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const panelEl = () => document.querySelector<HTMLElement>('[tabindex="-1"]')!;
|
||||
const closeBtn = () => screen.getByRole("button", { name: "common.close" });
|
||||
|
||||
// A modal with three extra focusable controls; DOM order inside the panel is [close, A, input, B].
|
||||
function Body() {
|
||||
return (
|
||||
<>
|
||||
<button>alpha</button>
|
||||
<input aria-label="field" />
|
||||
<button>beta</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
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;
|
||||
});
|
||||
|
||||
it("moves initial focus to the first control on open", () => {
|
||||
render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
expect(document.activeElement).toBe(closeBtn());
|
||||
});
|
||||
|
||||
it("Tab from the last control wraps to the first", () => {
|
||||
render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
screen.getByText("beta").focus(); // last
|
||||
press("Tab");
|
||||
expect(document.activeElement).toBe(closeBtn());
|
||||
});
|
||||
|
||||
it("Shift+Tab from the first control wraps to the last", () => {
|
||||
render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
closeBtn().focus(); // first
|
||||
press("Tab", true);
|
||||
expect(document.activeElement).toBe(screen.getByText("beta"));
|
||||
});
|
||||
|
||||
it("Tab pulls focus back inside when it has escaped to the page behind", () => {
|
||||
const outside = document.createElement("button");
|
||||
document.body.appendChild(outside);
|
||||
render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
outside.focus(); // focus escaped the dialog
|
||||
expect(document.activeElement).toBe(outside);
|
||||
press("Tab");
|
||||
expect(document.activeElement).toBe(closeBtn());
|
||||
document.body.removeChild(outside);
|
||||
});
|
||||
|
||||
it("Shift+Tab from the panel container itself goes to the last control (no leak to the background)", () => {
|
||||
render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
panelEl().focus(); // active === panel (tabIndex=-1, reached by clicking dead space)
|
||||
press("Tab", true);
|
||||
expect(document.activeElement).toBe(screen.getByText("beta"));
|
||||
});
|
||||
|
||||
it("does not trap a Tab in the middle — only the edges wrap", () => {
|
||||
render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
const mid = screen.getByLabelText("field");
|
||||
mid.focus();
|
||||
press("Tab"); // interior: native Tab handles it, the trap must not force-move
|
||||
expect(document.activeElement).toBe(mid);
|
||||
});
|
||||
|
||||
it("Escape calls onClose", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal title="t" onClose={onClose}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Escape closes only the topmost modal", () => {
|
||||
const outerClose = vi.fn();
|
||||
const innerClose = vi.fn();
|
||||
render(
|
||||
<Modal title="outer" onClose={outerClose}>
|
||||
<button>outer-body</button>
|
||||
</Modal>,
|
||||
);
|
||||
render(
|
||||
<Modal title="inner" onClose={innerClose}>
|
||||
<button>inner-body</button>
|
||||
</Modal>,
|
||||
);
|
||||
press("Escape");
|
||||
expect(innerClose).toHaveBeenCalledTimes(1);
|
||||
expect(outerClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores focus to the trigger on unmount", () => {
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus(); // captured as restoreTo during Modal's first render
|
||||
const { unmount } = render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
expect(document.activeElement).not.toBe(trigger); // focus moved into the dialog
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal focus trap (raw jsdom — every offsetParent null)", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("with no visible focusables: initial focus falls to the panel and Tab is a safe no-op", () => {
|
||||
render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
// focusablesIn() returns [] in raw jsdom, so the initializer focuses the panel itself.
|
||||
expect(document.activeElement).toBe(panelEl());
|
||||
expect(() => press("Tab")).not.toThrow();
|
||||
expect(document.activeElement).toBe(panelEl());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user