content-explorer/ui/lib/table-view.ts
tegwick 0cbc7baf49 Keep search, facets, and sort in the page URL.
CONTENT-WP-0002-T04 writes q, facet values, and sort into history so back and forward restore the table.

Assistant: grok
Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b
2026-10-13 23:12:00 +02:00

113 lines
3.8 KiB
TypeScript

/*
* URL shape adapted from OnCo viewParams: `q` for search, repeated facet keys,
* and `sort` with a leading "-" for descending.
* https://github.com/judegomila/OnCo @ b594d7433bf4e408b44215e25e5304696d6f840e
* Copyright (c) 2026 OnCo. MIT License. See ui/NOTICE.
*
* The compact `v` parameter, table scopes, and domain value normalisers are not included.
*/
import { cellSortValue, type BrowserRow } from "./browser-row";
import { sortRows } from "./sort-rows";
import type { SortState } from "./types";
export type TableView = {
q: string;
facets: Record<string, string[]>;
sort?: SortState;
};
export type ViewHistory = {
read: () => string;
push: (search: string) => void;
subscribe: (listener: () => void) => () => void;
};
export function browserViewHistory(): ViewHistory {
return {
read: () => (typeof window === "undefined" ? "" : window.location.search),
push: (search) => {
const path = window.location.pathname;
window.history.pushState(null, "", search ? `${path}?${search}` : path);
},
subscribe: (listener) => {
window.addEventListener("popstate", listener);
return () => window.removeEventListener("popstate", listener);
},
};
}
export function facetKeysOf(rows: readonly BrowserRow[]): string[] {
const keys = new Set<string>();
for (const row of rows) {
for (const key of Object.keys(row.facets)) keys.add(key);
}
return [...keys].sort();
}
export function facetValues(rows: readonly BrowserRow[], key: string): string[] {
const values = new Set<string>();
for (const row of rows) {
for (const value of row.facets[key] ?? []) values.add(value);
}
return [...values].sort((a, b) => a.localeCompare(b));
}
function splitValues(params: URLSearchParams, key: string): string[] {
const values: string[] = [];
for (const raw of params.getAll(key)) {
for (const part of raw.split(",")) {
const trimmed = part.trim();
if (trimmed && !values.includes(trimmed)) values.push(trimmed);
}
}
return values;
}
export function parseTableView(
search: string,
facetKeys: readonly string[],
sortable: (key: string) => boolean,
): TableView {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
const facets: Record<string, string[]> = {};
for (const key of facetKeys) {
const values = splitValues(params, key);
if (values.length > 0) facets[key] = values;
}
const rawSort = params.get("sort");
let sort: SortState | undefined;
if (rawSort) {
const key = rawSort.replace(/^-/, "");
if (sortable(key)) sort = { key, dir: rawSort.startsWith("-") ? -1 : 1 };
}
return { q: params.get("q") ?? "", facets, sort };
}
export function serializeTableView(view: TableView, baseSort: SortState): string {
const params = new URLSearchParams();
for (const key of Object.keys(view.facets).sort()) {
for (const value of view.facets[key] ?? []) params.append(key, value);
}
const q = view.q.trim();
if (q) params.set("q", q);
if (view.sort && (view.sort.key !== baseSort.key || view.sort.dir !== baseSort.dir)) {
params.set("sort", `${view.sort.dir === -1 ? "-" : ""}${view.sort.key}`);
}
return params.toString();
}
export function applyTableView(rows: readonly BrowserRow[], view: TableView): BrowserRow[] {
const q = view.q.trim().toLowerCase();
const filtered = rows.filter((row) => {
for (const [key, selected] of Object.entries(view.facets)) {
if (selected.length === 0) continue;
const have = new Set(row.facets[key] ?? []);
if (!selected.some((value) => have.has(value))) return false;
}
if (!q) return true;
const haystack = [row.name, row.tldr, ...Object.values(row.facets).flat()].join("\n").toLowerCase();
return haystack.includes(q);
});
return sortRows(filtered, view.sort, cellSortValue);
}