- DataTable: keep `pagerNode` NULLABLE. Extracting Pager made it an always-truthy element, so the `controls` / `bottomControls` guards always fired and a table with nothing to page rendered empty `my-3` bands (plus an empty in-band PageToolbar). - ChannelsProvider: `setView` bails when the tab is unchanged (re-selecting the active tab, or goToFullHistory forcing "subscribed", stacked dead Back entries), and pushes a FRESH state like setPage does instead of spreading `_chan`/`_yt` markers into the tab entry. - Lift the layout into its own narrow context (`useChannelLayout`): App read the whole ChannelsState for the fade, so every keystroke in the manager's search box re-rendered the app root. - Reset the card page when the row set or sort changes (both tabs) — the clamp only hid a stale page, which came back when the filter was cleared. - Sort + slice the card rows only in cards mode; table mode was copy-sorting ~300 rows per render for a result nothing read. - Validate persisted `cardSize` / `sort` (`parseCardSize`, `parseSortState`), matching the clamp-on-read convention the module's other persisted values already follow; a corrupt size made the page count NaN and emptied the grid. - Pager: page-size changes scroll to the top too (they re-slice from a new first item). - ColumnSortControl: a stored sort naming a column this table no longer offers is treated as no sort, instead of showing a direction arrow for a sort that isn't applied. - Drop the stale "rows" layout comments (that variant was retired this sprint).
43 lines
1.8 KiB
TypeScript
43 lines
1.8 KiB
TypeScript
// Shared column-sort primitives, lifted out of DataTable so a NON-table renderer (the channel
|
|
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
|
|
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
|
|
|
|
export type SortState = { key: string; dir: "asc" | "desc" } | null;
|
|
|
|
interface Sortable<T> {
|
|
key: string;
|
|
sortValue?: (row: T) => string | number;
|
|
}
|
|
|
|
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare).
|
|
* Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */
|
|
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState): T[] {
|
|
if (!sort) return rows;
|
|
const get = columns.find((c) => c.key === sort.key)?.sortValue;
|
|
if (!get) return rows;
|
|
return [...rows].sort((a, b) => {
|
|
const av = get(a);
|
|
const bv = get(b);
|
|
const cmp =
|
|
typeof av === "number" && typeof bv === "number"
|
|
? av - bv
|
|
: String(av).localeCompare(String(bv));
|
|
return sort.dir === "asc" ? cmp : -cmp;
|
|
});
|
|
}
|
|
|
|
/** Coerce a stored value to a SortState. Anything malformed (corrupt storage, a shape from an older
|
|
* build) becomes "no sort" rather than flowing a junk `dir` into the comparator. */
|
|
export function parseSortState(raw: unknown): SortState {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const { key, dir } = raw as { key?: unknown; dir?: unknown };
|
|
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
|
|
}
|
|
|
|
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
|
|
export function cycleSort(current: SortState, key: string): SortState {
|
|
if (!current || current.key !== key) return { key, dir: "asc" };
|
|
if (current.dir === "asc") return { key, dir: "desc" };
|
|
return null;
|
|
}
|