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
This commit is contained in:
tegwick 2026-10-13 23:12:00 +02:00
parent d92eb25f92
commit 0cbc7baf49
8 changed files with 503 additions and 9 deletions

View file

@ -0,0 +1,168 @@
// @vitest-environment happy-dom
import { createRoot, type Root } from "react-dom/client";
import { act } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { mapBrowserRow, type ColumnDef } from "@/lib/browser-row";
import { browserViewHistory, type ViewHistory } from "@/lib/table-view";
import { RecordBrowser } from "./RecordBrowser";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const columns: ColumnDef[] = [
{ key: "name", label: "Name" },
{ key: "tldr", label: "Summary" },
];
const rows = [
mapBrowserRow(
{ id: "a", title: "Alpha", tldr: "First", tags: ["one"], kindLabel: "Concept", route: "/a/", kindHref: "/concept/" },
["tags"],
),
mapBrowserRow(
{ id: "z", title: "Zebra", tldr: "Last", tags: ["two"], kindLabel: "Concept", route: "/z/", kindHref: "/concept/" },
["tags"],
),
];
let root: Root | null = null;
afterEach(() => {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});
function names(): string[] {
return [...document.querySelectorAll("tbody tr td:first-child")].map((cell) => cell.textContent ?? "");
}
function buttonNamed(label: string): HTMLButtonElement {
const node = [...document.querySelectorAll("button")].find((item) => item.textContent?.includes(label));
if (!(node instanceof HTMLButtonElement)) throw new Error(`missing ${label}`);
return node;
}
/** Same-document session history: back and forward keep this page and report the new query. */
function sessionHistory(): ViewHistory & { back: () => void; forward: () => void; location: () => string } {
const stack = [""];
let index = 0;
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
return {
read: () => (stack[index] ? `?${stack[index]}` : ""),
push: (search) => {
stack.splice(index + 1);
stack.push(search);
index = stack.length - 1;
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
back: () => {
if (index === 0) return;
index -= 1;
notify();
},
forward: () => {
if (index >= stack.length - 1) return;
index += 1;
notify();
},
location: () => stack[index],
};
}
describe("record browser history", () => {
it("reads a pushed query and a popstate traversal", () => {
window.history.replaceState(null, "", "/records/");
const history = browserViewHistory();
const seen: string[] = [];
const stop = history.subscribe(() => seen.push(history.read()));
history.push("q=zebra");
expect(window.location.search).toBe("?q=zebra");
expect(seen).toEqual([]);
window.history.pushState(null, "", "/records/?tags=one");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(seen).toEqual(["?tags=one"]);
stop();
});
it("restores search, facets, and sort on back and forward", async () => {
const history = sessionHistory();
const host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
await act(async () => {
root?.render(<RecordBrowser rows={rows} columns={columns} history={history} />);
});
expect(names()).toEqual(["Alpha", "Zebra"]);
const input = document.querySelector("input[aria-label='Search records']");
if (!(input instanceof HTMLInputElement)) throw new Error("missing search");
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setValue?.call(input, "zebra");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
input.form?.requestSubmit();
});
expect(history.location()).toContain("q=zebra");
expect(names()).toEqual(["Zebra"]);
await act(async () => {
buttonNamed("two").click();
});
expect(history.location()).toContain("tags=two");
expect(buttonNamed("two").getAttribute("aria-pressed")).toBe("true");
await act(async () => {
buttonNamed("Name").click();
});
expect(history.location()).toContain("sort=-name");
expect(names()).toEqual(["Zebra"]);
await act(async () => {
history.back();
});
expect(history.location()).not.toContain("sort=");
expect(history.location()).toContain("tags=two");
await act(async () => {
history.back();
});
expect(history.location()).toBe("q=zebra");
expect(buttonNamed("two").getAttribute("aria-pressed")).toBe("false");
expect(names()).toEqual(["Zebra"]);
await act(async () => {
history.back();
});
expect(history.location()).toBe("");
expect(names()).toEqual(["Alpha", "Zebra"]);
await act(async () => {
history.forward();
});
expect(history.location()).toBe("q=zebra");
expect(names()).toEqual(["Zebra"]);
await act(async () => {
history.forward();
});
expect(history.location()).toContain("tags=two");
await act(async () => {
history.forward();
});
expect(history.location()).toContain("sort=-name");
expect(names()).toEqual(["Zebra"]);
});
});

View file

@ -1,40 +1,126 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { renderCell, type CellRenderer } from "@/components/cell-renderers";
import { ResultsTable } from "@/components/ResultsTable";
import { cellSortValue, type BrowserRow, type ColumnDef } from "@/lib/browser-row";
import { nextSort, sortRows } from "@/lib/sort-rows";
import { nextSort } from "@/lib/sort-rows";
import {
applyTableView,
browserViewHistory,
facetKeysOf,
facetValues,
parseTableView,
serializeTableView,
type TableView,
type ViewHistory,
} from "@/lib/table-view";
import type { SortState } from "@/lib/types";
const FACET_LABELS: Record<string, string> = { tags: "Tags" };
export function RecordBrowser({
rows,
columns,
heading = "Records",
renderers,
history,
}: {
rows: BrowserRow[];
columns: ColumnDef[];
heading?: string;
renderers?: Record<string, CellRenderer>;
history?: ViewHistory;
}) {
const [sort, setSort] = useState<SortState | undefined>({ key: columns[0]?.key ?? "name", dir: 1 });
const ordered = sortRows(rows, sort, cellSortValue);
const viewHistory = useMemo(() => history ?? browserViewHistory(), [history]);
const facetKeys = useMemo(() => facetKeysOf(rows), [rows]);
const sortable = (key: string) => columns.some((column) => column.key === key);
const baseSort: SortState = { key: columns[0]?.key ?? "name", dir: 1 };
const [search, setSearch] = useState("");
const [draft, setDraft] = useState("");
const view = parseTableView(search, facetKeys, sortable);
const sort = view.sort ?? baseSort;
const visible = applyTableView(rows, { ...view, sort });
useEffect(() => {
const read = () => {
const next = viewHistory.read();
setSearch(next);
setDraft(parseTableView(next, facetKeys, sortable).q);
};
read();
return viewHistory.subscribe(read);
}, [viewHistory, facetKeys, columns]);
function commit(next: TableView) {
const query = serializeTableView(next, baseSort);
viewHistory.push(query);
setSearch(query ? `?${query}` : "");
setDraft(next.q);
}
function toggleFacet(key: string, value: string) {
const current = view.facets[key] ?? [];
const values = current.includes(value) ? current.filter((item) => item !== value) : [...current, value];
commit({ q: view.q, sort, facets: { ...view.facets, [key]: values } });
}
const tableColumns = columns.map((column) => ({
key: column.key,
label: column.label,
sortable: true,
render: (row: BrowserRow) => renderCell(column.key, row, renderers),
}));
return (
<section id="records" aria-labelledby="records-heading" className="mx-auto max-w-7xl px-4 py-14 sm:px-6">
<h2 id="records-heading" className="mb-6 text-2xl">{heading}</h2>
<form
className="mb-4 flex flex-wrap items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
commit({ q: draft, sort, facets: view.facets });
}}
>
<label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className="sr-only">Search records</span>
<input
aria-label="Search records"
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="h-10 w-full max-w-md rounded-[0.625rem] border border-border bg-card px-3 text-sm"
/>
</label>
<button type="submit" className="btn">Search</button>
</form>
{facetKeys.map((key) => {
const values = facetValues(rows, key);
if (values.length === 0) return null;
const selected = view.facets[key] ?? [];
return (
<div key={key} className="mb-4 flex flex-wrap items-center gap-2" role="group" aria-label={FACET_LABELS[key] ?? key}>
<span className="kicker">{FACET_LABELS[key] ?? key}</span>
{values.map((value) => (
<button
key={value}
type="button"
className={selected.includes(value) ? "btn btn-primary" : "btn"}
aria-pressed={selected.includes(value)}
onClick={() => toggleFacet(key, value)}
>
{value}
</button>
))}
</div>
);
})}
<ResultsTable
columns={tableColumns}
rows={ordered}
rows={visible}
rowKey={(row) => 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."
/>
</section>
);