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.
104 lines
3.7 KiB
TypeScript
104 lines
3.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The rendering integration of SortControl (its pickSort/heal decision logic is covered in
|
|
// columnSort.test.ts). Labels are plain props here — no i18n needed. It's a floating-ui popover now
|
|
// (a labeled pill + a menu of `menuitemradio` sort keys with a footer direction segment), so we stub
|
|
// the two autoUpdate observers and wrap in a FloatingTree; rows only exist while open → role queries
|
|
// pass { hidden: true }.
|
|
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 SortControl from "./SortControl";
|
|
import type { SortOption, SortState } from "../../lib/columnSort";
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
const OPTS: SortOption[] = [
|
|
{ key: "date", label: "Date", defaultDir: "desc" },
|
|
{ key: "title", label: "Title" },
|
|
{ key: "shuffle", label: "Shuffle", directionless: true },
|
|
];
|
|
|
|
function renderControl(sort: SortState, extra: Record<string, unknown> = {}) {
|
|
const onChange = vi.fn();
|
|
render(
|
|
<FloatingTree>
|
|
<SortControl
|
|
options={OPTS}
|
|
sort={sort}
|
|
onChange={onChange}
|
|
label="Sort"
|
|
ascLabel="Ascending"
|
|
descLabel="Descending"
|
|
{...extra}
|
|
/>
|
|
</FloatingTree>,
|
|
);
|
|
return { onChange };
|
|
}
|
|
|
|
const openPanel = () => fireEvent.click(screen.getByRole("button", { name: /^Sort/ }));
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("SortControl", () => {
|
|
it("renders one option per SortOption, no no-sort row without defaultLabel", () => {
|
|
renderControl({ key: "title", dir: "asc" });
|
|
openPanel();
|
|
expect(
|
|
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
|
|
).toEqual(["Date", "Title", "Shuffle"]);
|
|
});
|
|
|
|
it("adds a leading no-sort option when defaultLabel is set (tables)", () => {
|
|
renderControl(null, { defaultLabel: "None" });
|
|
openPanel();
|
|
expect(
|
|
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
|
|
).toEqual(["None", "Date", "Title", "Shuffle"]);
|
|
});
|
|
|
|
it("summarises the trigger with the first option (not blank) for an unavailable key without defaultLabel", () => {
|
|
renderControl({ key: "ghost", dir: "asc" });
|
|
expect(screen.getByRole("button", { name: /^Sort/ }).textContent).toContain("Date");
|
|
});
|
|
|
|
it("picking a new option fires onChange at that option's defaultDir", () => {
|
|
const { onChange } = renderControl({ key: "title", dir: "asc" });
|
|
openPanel();
|
|
fireEvent.click(screen.getAllByRole("menuitemradio", { hidden: true })[0]!); // Date
|
|
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "desc" });
|
|
});
|
|
|
|
it("shows the direction segment for a directional sort and sets asc on click", () => {
|
|
const { onChange } = renderControl({ key: "date", dir: "desc" });
|
|
openPanel();
|
|
fireEvent.click(screen.getByText("Ascending"));
|
|
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "asc" });
|
|
});
|
|
|
|
it("hides the direction segment for a directionless sort", () => {
|
|
renderControl({ key: "shuffle", dir: "desc" });
|
|
openPanel();
|
|
expect(screen.queryByText("Ascending")).toBeNull();
|
|
expect(screen.queryByText("Descending")).toBeNull();
|
|
});
|
|
|
|
it("renders the trailing adornment in the footer", () => {
|
|
renderControl({ key: "shuffle", dir: "desc" }, { trailing: <button>Reshuffle</button> });
|
|
openPanel();
|
|
expect(screen.getByText("Reshuffle")).toBeTruthy();
|
|
});
|
|
});
|