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:
2026-08-21 18:11:45 +02:00
parent 6109e0c6f1
commit 1be1dc9d06
4 changed files with 182 additions and 73 deletions
@@ -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();
});
});