{heading}
+
+ {facetKeys.map((key) => {
+ const values = facetValues(rows, key);
+ if (values.length === 0) return null;
+ const selected = view.facets[key] ?? [];
+ return (
+
+ {FACET_LABELS[key] ?? key}
+ {values.map((value) => (
+
+ ))}
+
+ );
+ })}
row.id}
sort={sort}
- onSort={(key) => setSort((current) => nextSort(current, key))}
+ onSort={(key) => commit({ q: view.q, facets: view.facets, sort: nextSort(sort, key) })}
+ empty="No records match this view."
/>
);
diff --git a/ui/lib/table-view.test.ts b/ui/lib/table-view.test.ts
new file mode 100644
index 0000000..e0c8cb9
--- /dev/null
+++ b/ui/lib/table-view.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import { mapBrowserRow } from "./browser-row";
+import { applyTableView, parseTableView, serializeTableView } from "./table-view";
+
+const rows = [
+ mapBrowserRow(
+ {
+ id: "a",
+ title: "Alpha",
+ tldr: "First idea",
+ tags: ["one"],
+ kindLabel: "Concept",
+ route: "/a/",
+ kindHref: "/concept/",
+ },
+ ["tags"],
+ ),
+ mapBrowserRow(
+ {
+ id: "z",
+ title: "Zebra",
+ tldr: "Last idea",
+ tags: ["two"],
+ kindLabel: "Concept",
+ route: "/z/",
+ kindHref: "/concept/",
+ },
+ ["tags"],
+ ),
+];
+
+const sortable = (key: string) => key === "name" || key === "tldr" || key === "tags";
+const base = { key: "name", dir: 1 as const };
+
+describe("table view", () => {
+ it("round-trips search, repeated facets, and descending sort", () => {
+ const view = {
+ q: "idea",
+ facets: { tags: ["one", "two"] },
+ sort: { key: "name", dir: -1 as const },
+ };
+ const search = serializeTableView(view, base);
+ expect(search).toContain("q=idea");
+ expect(search).toContain("tags=one");
+ expect(search).toContain("tags=two");
+ expect(search).toContain("sort=-name");
+ expect(parseTableView(`?${search}`, ["tags"], sortable)).toEqual(view);
+ expect(serializeTableView({ q: "", facets: {}, sort: base }, base)).toBe("");
+ });
+
+ it("filters by facet and query, then sorts", () => {
+ const both = applyTableView(rows, parseTableView("?tags=one,two&sort=-name", ["tags"], sortable));
+ expect(both.map((row) => row.name)).toEqual(["Zebra", "Alpha"]);
+ const one = applyTableView(rows, parseTableView("?q=first&tags=one", ["tags"], sortable));
+ expect(one.map((row) => row.name)).toEqual(["Alpha"]);
+ const none = applyTableView(rows, parseTableView("?q=missing", ["tags"], sortable));
+ expect(none).toEqual([]);
+ });
+});
diff --git a/ui/lib/table-view.ts b/ui/lib/table-view.ts
new file mode 100644
index 0000000..614a8a2
--- /dev/null
+++ b/ui/lib/table-view.ts
@@ -0,0 +1,113 @@
+/*
+ * 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