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,127 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the reusable ⋯ overflow menu. Labels/handlers are plain props (no i18n), so we assert
|
||||
// the open/select/close + disabled-item behaviour. The menu content only mounts while open, and the
|
||||
// AnchoredPopover platform is floating-ui based, so we stub the two observers autoUpdate wires and
|
||||
// wrap in a FloatingTree (its real-app parent) so nested-layer dispatch has a node to register.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||
|
||||
beforeAll(() => {
|
||||
// floating-ui's autoUpdate observes with ResizeObserver + IntersectionObserver (layoutShift); jsdom
|
||||
// ships neither, and the floating element mounts (so they run) the moment the menu opens.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const renderMenu = (items: ActionMenuItem[]) =>
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ActionMenu ariaLabel="Catalog actions" items={items} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
|
||||
describe("ActionMenu", () => {
|
||||
it("renders a labelled trigger and keeps items unmounted until opened", () => {
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
|
||||
expect(screen.getByRole("button", { name: "Catalog actions" })).toBeTruthy();
|
||||
expect(screen.queryByRole("menuitem")).toBeNull();
|
||||
});
|
||||
|
||||
// floating-ui never computes a position in jsdom, so the surface stays visibility:hidden; the items
|
||||
// are in the DOM but the a11y tree treats them as hidden — hence `{ hidden: true }` on role queries.
|
||||
it("opens on click and shows one menuitem per item", () => {
|
||||
renderMenu([
|
||||
{ id: "sync", label: "Sync", onSelect: vi.fn() },
|
||||
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([
|
||||
"Sync",
|
||||
"Backfill",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selecting an item fires its onSelect and closes the menu", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
fireEvent.click(screen.getByText("Sync"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
const item = screen.getByText("Sync").closest("button")!;
|
||||
expect((item as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(item);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a disabled item in roving keyboard nav", () => {
|
||||
// items[0] disabled, items[1] + items[2] enabled. Roving keyboard focus (the `tabindex=0` item,
|
||||
// which tracks useListNavigation's activeIndex) must NEVER land on the disabled index-0 — arrowing
|
||||
// skips straight over it. The skip comes for free: PopoverItem renders a native `disabled` button
|
||||
// and useListNavigation reads that DOM state (no explicit disabledIndices needed). Without it,
|
||||
// ArrowUp/Down would strand roving focus on the inert index-0 button. (floating-ui wires this on
|
||||
// real key nav, not on open, in jsdom — so we drive it with arrow keys.)
|
||||
renderMenu([
|
||||
{ id: "sync", label: "Sync", onSelect: vi.fn(), disabled: true },
|
||||
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
|
||||
{ id: "reset", label: "Reset", onSelect: vi.fn() },
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
const [disabled, backfill, reset] = screen.getAllByRole("menuitem", { hidden: true }) as [
|
||||
HTMLElement,
|
||||
HTMLElement,
|
||||
HTMLElement,
|
||||
];
|
||||
const menu = screen.getByRole("menu", { hidden: true });
|
||||
const rover = () =>
|
||||
[disabled, backfill, reset].find((el) => el.getAttribute("tabindex") === "0")?.textContent;
|
||||
|
||||
// The disabled item never becomes the roving target, whichever way we arrow.
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" }); // skips disabled[0] -> first enabled
|
||||
expect(rover()).toBe("Backfill");
|
||||
expect(disabled.getAttribute("tabindex")).toBe("-1");
|
||||
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(rover()).toBe("Reset");
|
||||
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" }); // back to Backfill
|
||||
expect(rover()).toBe("Backfill");
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" }); // wraps over disabled[0] to the last item
|
||||
expect(rover()).toBe("Reset");
|
||||
expect(disabled.getAttribute("tabindex")).toBe("-1"); // still never the disabled item
|
||||
});
|
||||
|
||||
it("honours a custom trigger node", () => {
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
|
||||
cleanup();
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ActionMenu
|
||||
ariaLabel="More"
|
||||
items={[{ id: "x", label: "X", onSelect: vi.fn() }]}
|
||||
trigger={<span data-testid="custom-trigger">•••</span>}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
expect(screen.getByTestId("custom-trigger")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user