From 1be1dc9d06e7d126479762cc1a535a4398f1b72e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 21 Aug 2026 18:11:45 +0200 Subject: [PATCH] feat(sort): give SortControl the FilterSelect pill look with a footer direction segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/Feed.tsx | 1 - frontend/src/components/PlexBrowse.tsx | 1 - .../src/components/SortControl.dom.test.tsx | 87 +++++---- frontend/src/components/SortControl.tsx | 166 +++++++++++++----- 4 files changed, 182 insertions(+), 73 deletions(-) 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} -
+