feat(sort): give SortControl the FilterSelect pill look with a footer direction segment
Rebuilds SortControl's internals (API unchanged, so all five consumers — feed, Plex, playlists, and the two table/card ColumnSortControl sites — get it for free) as a labeled "Label: key ↓ ▾" pill on the AnchoredPopover platform, matching FilterSelect. The listbox lists the sort keys; an asc/desc segment sits in the panel footer (with the trailing slot — feed reshuffle, playlists dirty-first — beside it). Two-dimensional key+direction, so it stays its own component rather than reusing FilterSelect: the trigger is a single plain button (no nested button), picking a key keeps the panel open so key AND direction can be set in one visit, and picking "no sort" (tables' defaultLabel) closes it. Directionless keys (shuffle) hide the segment and drop the trigger arrow. Drops the now-redundant external "Sort" label spans in Feed and PlexBrowse (the trigger carries the label now). Rewrites SortControl.dom.test for the popover UI.
This commit is contained in:
@@ -1,12 +1,28 @@
|
||||
// @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.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
// columnSort.test.ts). Labels are plain props here — no i18n needed. It's a floating-ui popover now
|
||||
// (a labeled pill + a listbox with a footer direction segment), so we stub the two autoUpdate
|
||||
// observers and wrap in a FloatingTree; options 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" },
|
||||
@@ -16,25 +32,30 @@ const OPTS: SortOption[] = [
|
||||
function renderControl(sort: SortState, extra: Record<string, unknown> = {}) {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SortControl
|
||||
options={OPTS}
|
||||
sort={sort}
|
||||
onChange={onChange}
|
||||
label="Sort"
|
||||
ascLabel="Ascending"
|
||||
descLabel="Descending"
|
||||
{...extra}
|
||||
/>,
|
||||
<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 empty option without defaultLabel", () => {
|
||||
it("renders one option per SortOption, no no-sort row without defaultLabel", () => {
|
||||
renderControl({ key: "title", dir: "asc" });
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
openPanel();
|
||||
expect(screen.getAllByRole("option", { hidden: true }).map((o) => o.textContent)).toEqual([
|
||||
"Date",
|
||||
"Title",
|
||||
"Shuffle",
|
||||
@@ -43,40 +64,44 @@ describe("SortControl", () => {
|
||||
|
||||
it("adds a leading no-sort option when defaultLabel is set (tables)", () => {
|
||||
renderControl(null, { defaultLabel: "None" });
|
||||
const none = screen.getByRole("option", { name: "None" }) as HTMLOptionElement;
|
||||
expect(none.value).toBe("");
|
||||
openPanel();
|
||||
expect(screen.getAllByRole("option", { hidden: true }).map((o) => o.textContent)).toEqual([
|
||||
"None",
|
||||
"Date",
|
||||
"Title",
|
||||
"Shuffle",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the first option (not a blank select) for an unavailable key without defaultLabel", () => {
|
||||
it("summarises the trigger with the first option (not blank) for an unavailable key without defaultLabel", () => {
|
||||
renderControl({ key: "ghost", dir: "asc" });
|
||||
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("date");
|
||||
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" });
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "date" } });
|
||||
openPanel();
|
||||
fireEvent.click(screen.getAllByRole("option", { hidden: true })[0]!); // Date
|
||||
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "desc" });
|
||||
});
|
||||
|
||||
it("shows the direction toggle for a directional sort and flips it on click", () => {
|
||||
it("shows the direction segment for a directional sort and sets asc on click", () => {
|
||||
const { onChange } = renderControl({ key: "date", dir: "desc" });
|
||||
const toggle = screen.getByRole("button", { name: "Descending" });
|
||||
fireEvent.click(toggle);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByText("Ascending"));
|
||||
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "asc" });
|
||||
});
|
||||
|
||||
it("hides the direction toggle for a directionless sort", () => {
|
||||
it("hides the direction segment for a directionless sort", () => {
|
||||
renderControl({ key: "shuffle", dir: "desc" });
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
openPanel();
|
||||
expect(screen.queryByText("Ascending")).toBeNull();
|
||||
expect(screen.queryByText("Descending")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the trailing adornment", () => {
|
||||
renderControl(
|
||||
{ key: "shuffle", dir: "desc" },
|
||||
{
|
||||
trailing: <button>Reshuffle</button>,
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Reshuffle" })).toBeTruthy();
|
||||
it("renders the trailing adornment in the footer", () => {
|
||||
renderControl({ key: "shuffle", dir: "desc" }, { trailing: <button>Reshuffle</button> });
|
||||
openPanel();
|
||||
expect(screen.getByText("Reshuffle")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user