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
View File
@@ -626,7 +626,6 @@ export default function Feed({
: " "}
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
-1
View File
@@ -399,7 +399,6 @@ export default function PlexBrowse() {
</p>
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
<div className="ml-auto flex items-center gap-2">
<span className="hidden sm:inline text-xs text-muted">{t("plex.filter.sort")}</span>
<SortControl
options={sortOptions}
sort={sortState}
@@ -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();
});
});
+126 -40
View File
@@ -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
// <button> (the popover reference) with no nested button — picking a key keeps the panel open so key
// AND direction can be set in one visit; picking "no sort" closes it (nothing left to direct).
export default function SortControl({
options,
sort,
@@ -21,55 +27,135 @@ export default function SortControl({
options: SortOption[];
sort: SortState;
onChange: (next: SortState) => void;
/** Accessible name for the dropdown (e.g. "Sort"). */
/** Accessible name + visible prefix for the trigger (e.g. "Sort"). */
label: string;
ascLabel: string;
descLabel: string;
/** When set, a leading "no sort / original order" option is offered (tables). Omit for
* always-sorted views (feed/Plex), which never surface a null sort. */
defaultLabel?: string;
/** Rendered after the direction toggle (e.g. the feed's reshuffle button). */
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
trailing?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "listbox",
placement: "bottom-end",
});
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
const active = sort && options.some((o) => o.key === sort.key) ? sort : null;
const activeOpt = active ? options.find((o) => o.key === active.key) : undefined;
const showArrow = !!activeOpt && !activeOpt.directionless;
// An always-sorted view (no `defaultLabel`) must still show a real option when the current key isn't
// offered (a stale/out-of-scope sort) — fall back to the first option rather than a blank select
// (which also trips React's controlled-value warning). Views with a "no sort" option keep it.
const selectValue = active?.key ?? (defaultLabel === undefined ? (options[0]?.key ?? "") : "");
// Trigger summary: the active option's label, or the "no sort" text, or (a stale/out-of-scope sort on
// an always-sorted view) the first option's label rather than a blank pill.
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
const segCls = (on: boolean) =>
`flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-2xs transition ${
on
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`;
return (
<div className="flex items-center gap-1">
<select
value={selectValue}
onChange={(e) => onChange(pickSort(options, active, e.target.value))}
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={label}
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition ${
open ? "border-accent" : "border-border"
}`}
>
{defaultLabel !== undefined && <option value="">{defaultLabel}</option>}
{options.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
{showArrow && active && (
<button
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
title={active.dir === "asc" ? ascLabel : descLabel}
aria-label={active.dir === "asc" ? ascLabel : descLabel}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
{active.dir === "asc" ? (
<ArrowUp className="w-4 h-4" />
<span className="text-muted">{label}:</span>
<span className="text-fg">{summary}</span>
{showArrow &&
active &&
(active.dir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
{trailing}
</div>
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
))}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{defaultLabel !== undefined && (
<PopoverItem
role="option"
selected={active === null}
onSelect={() => {
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"
}`}
>
<Check
className={`h-3.5 w-3.5 shrink-0 ${active === null ? "text-accent" : "opacity-0"}`}
/>
<span className="flex-1 truncate">{defaultLabel}</span>
</PopoverItem>
)}
{options.map((o) => {
const on = active?.key === o.key;
return (
<PopoverItem
key={o.key}
role="option"
selected={on}
onSelect={() => 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"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
{((showArrow && active) || trailing) && (
<div
role="group"
aria-label={label}
className="mt-1 flex items-center gap-1.5 border-t border-border px-1 pt-2"
>
{showArrow && active && (
<div className="flex flex-1 gap-1">
<button
type="button"
onClick={() => onChange({ key: active.key, dir: "asc" })}
className={segCls(active.dir === "asc")}
>
<ArrowUp className="h-3 w-3" aria-hidden="true" />
{ascLabel}
</button>
<button
type="button"
onClick={() => onChange({ key: active.key, dir: "desc" })}
className={segCls(active.dir === "desc")}
>
<ArrowDown className="h-3 w-3" aria-hidden="true" />
{descLabel}
</button>
</div>
)}
{trailing}
</div>
)}
</PopoverSurface>
</>
);
}