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
+//
+
+
+ {defaultLabel !== undefined && (
+ {
+ onChange(null);
+ setOpen(false); // "no sort" is a final choice — nothing left to direct
+ }}
+ label={defaultLabel}
+ className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
+ active === null ? "text-fg" : "text-muted"
+ }`}
+ >
+
+ {defaultLabel}
+
+ )}
+ {options.map((o) => {
+ const on = active?.key === o.key;
+ return (
+ onChange(pickSort(options, active, o.key))} // keep open to set direction
+ label={o.label}
+ className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
+ on ? "text-fg" : "text-muted"
+ }`}
+ >
+
+ {o.label}
+
+ );
+ })}
+ {((showArrow && active) || trailing) && (
+
+ {showArrow && active && (
+
+
onChange({ key: active.key, dir: "asc" })}
+ className={segCls(active.dir === "asc")}
+ >
+
+ {ascLabel}
+
+
onChange({ key: active.key, dir: "desc" })}
+ className={segCls(active.dir === "desc")}
+ >
+
+ {descLabel}
+
+
+ )}
+ {trailing}
+
+ )}
+
+ >
);
}