refactor(sort): source-agnostic SortControl + SortOption model (R13 S1 foundation)

Introduce the one sort control every content surface will share:
- columnSort.ts gains the SortOption vocabulary + pure pickSort/healSort/
  hydrateSort helpers (direction lives in SortState, never in the key).
- SortControl.tsx: the generic dropdown + asc/desc toggle, with an optional
  "no sort" choice (tables) and a trailing adornment slot (the feed's reshuffle).
- ColumnSortControl is now a thin wrapper over SortControl (API unchanged, so
  Channels + ChannelDiscovery are untouched); optionsFromColumns moved to
  lib/sortOptions.ts to keep the component file fast-refresh clean.
- useSortState.ts: the per-account persist/hydrate/heal plumbing the channel
  manager and discovery each hand-rolled, ready to replace both.

Pure logic is fully unit-tested (columnSort.test.ts 10 -> 23). Gate green:
tsc/eslint/prettier clean, vitest 93 pass. Feed/Plex/channels migration follows.
This commit is contained in:
2026-08-13 23:24:59 +02:00
parent f3b95ed543
commit 7d9da80795
6 changed files with 260 additions and 44 deletions
+15 -43
View File
@@ -1,11 +1,12 @@
import { ArrowDown, ArrowUp } from "lucide-react";
import { type Column } from "./DataTable";
import { type SortState } from "../lib/columnSort";
import { optionsFromColumns } from "../lib/sortOptions";
import SortControl from "./SortControl";
// A sort control for views without clickable column headers (the channel manager's card layout):
// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's
// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The
// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list.
// The headerless sort control for views without clickable column headers (the channel manager's and
// discovery's card layouts). Now a thin adapter over the generic `SortControl`, feeding it the
// column-derived options and a "no sort" default — kept as its own component so the existing call
// sites stay untouched and table/cards keep sharing one `SortState`.
export default function ColumnSortControl<T>({
columns,
sort,
@@ -25,44 +26,15 @@ export default function ColumnSortControl<T>({
ascLabel: string;
descLabel: string;
}) {
const sortable = columns.filter((c) => c.sortable && c.sortValue);
// A stored sort can name a column this table no longer offers (renamed, or `sortable` dropped).
// `sortRows` already leaves the rows unsorted then, so treat it as no sort here too — otherwise the
// select falls back to showing "default" while a direction arrow claims a sort that isn't applied.
const active = sortable.some((c) => c.key === sort?.key) ? sort : null;
return (
<div className="flex items-center gap-1">
<select
value={active?.key ?? ""}
onChange={(e) => {
const key = e.target.value;
// Keep the direction when re-picking the same column; a new column starts ascending.
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
}}
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"
>
<option value="">{defaultLabel}</option>
{sortable.map((c) => (
<option key={c.key} value={c.key}>
{c.header}
</option>
))}
</select>
{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" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
</div>
<SortControl
options={optionsFromColumns(columns)}
sort={sort}
onChange={onChange}
label={label}
defaultLabel={defaultLabel}
ascLabel={ascLabel}
descLabel={descLabel}
/>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { type ReactNode } from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { pickSort, type SortOption, type SortState } from "../lib/columnSort";
// 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.
export default function SortControl({
options,
sort,
onChange,
label,
ascLabel,
descLabel,
defaultLabel,
trailing,
}: {
options: SortOption[];
sort: SortState;
onChange: (next: SortState) => void;
/** Accessible name for the dropdown (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). */
trailing?: ReactNode;
}) {
// 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;
return (
<div className="flex items-center gap-1">
<select
value={active?.key ?? ""}
onChange={(e) => onChange(pickSort(options, active, e.target.value))}
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"
>
{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" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
{trailing}
</div>
);
}