diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 68c9be33..c4fe5ec5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -626,7 +626,6 @@ export default function Feed({ : " "}
- {t("feed.sortLabel")} {/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
- {t("plex.filter.sort")} { + 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 = {}) { const onChange = vi.fn(); render( - , + + + , ); 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: , - }, - ); - expect(screen.getByRole("button", { name: "Reshuffle" })).toBeTruthy(); + it("renders the trailing adornment in the footer", () => { + renderControl({ key: "shuffle", dir: "desc" }, { trailing: }); + openPanel(); + expect(screen.getByText("Reshuffle")).toBeTruthy(); }); }); diff --git a/frontend/src/components/SortControl.tsx b/frontend/src/components/SortControl.tsx index cf3be5c6..19d0d374 100644 --- a/frontend/src/components/SortControl.tsx +++ b/frontend/src/components/SortControl.tsx @@ -1,13 +1,19 @@ -import { type ReactNode } from "react"; -import { ArrowDown, ArrowUp } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react"; import { pickSort, type SortOption, type SortState } from "../lib/columnSort"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; // The one sort control for every content surface (YT feed, Plex library, channel manager + its card -// view, channel discovery): a dropdown of `SortOption`s + an asc/desc toggle, driven by the shared -// `SortState`. Presentation-agnostic to its container — a table feeds column-derived options via -// `ColumnSortControl`, the feed/Plex declare their own vocabulary. `defaultLabel` opts a view into a -// "no sort" choice (tables); omit it for always-sorted views (feed/Plex). `trailing` is an adornment -// slot (the feed's reshuffle button) so the shuffle affordance stays out of the generic control. +// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾` +// — that opens a listbox of `SortOption`s with an asc/desc segment in the panel footer. Two-dimensional +// (key + direction) unlike FilterSelect, so it stays its own component. Driven by the shared `SortState`. +// `defaultLabel` opts a view into a "no sort" choice (tables); omit it for always-sorted views +// (feed/Plex). `trailing` is an adornment slot (the feed's reshuffle, playlists' dirty-first toggle) +// rendered in the footer beside the direction segment. +// +// Direction lives in the footer, not on the trigger, on purpose: the trigger stays a single plain +// - )} - {trailing} -
+