Files
siftlode/frontend/src/components/SortControl.dom.test.tsx
T
peter 5e1cc4d981 fix(a11y,test): deep-review round 2 — e2e helper attr, trigger names, observer test
Second gate round over the full branch surfaced 4 verified findings (1 was a
regression the round-1 role change introduced); a 5th (--sbw load jump) was
dropped as refuted (the header only mounts after auth+data, so --sbw is set
before it ever paints).

- e2e (medium): the round-1 switch of FilterSelect rows from role=option to
  menuitemcheckbox moved their state from aria-selected to aria-checked, but
  the Playwright helper expectContentType still asserted aria-selected, so the
  channel specs would fail whenever the e2e lane runs (not part of the fast
  gate, so it stayed green). Assert aria-checked.
- a11y (low): the FilterSelect and SortControl pill triggers set
  aria-label={label}, which overrides the visible "Label: value" text, so a
  screen reader announced only the group name, never the current selection.
  Carry the value (and, for sort, the direction) in the accessible name,
  matching ViewSwitcher. Updated the unit-test trigger name queries to match.
- tests (low): cover the PageShell font-scale MutationObserver recompute path
  (a post-mount root font-size change → --rail-pinned-w re-computes once, no
  loop) — previously only the pre-render and resize paths were exercised.

Verified live: the triggers now announce e.g. "Sort: Date, Descending" /
"Watch state: All". Gate green: tsc (app+node+e2e), 373 vitest, prettier,
eslint (0 err).
2026-08-22 01:06:22 +02:00

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();
});
});