CONTENT-WP-0002-T05 focuses search with slash, clears it with Escape, and wraps the header on a narrow screen. Assistant: grok Assistant-Session: 01a1346c-fdff-7741-b1af-b7283661554b
182 lines
6.4 KiB
TypeScript
182 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, 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 } 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,
|
|
status = "ready",
|
|
errorMessage = "Records could not be loaded.",
|
|
}: {
|
|
rows: BrowserRow[];
|
|
columns: ColumnDef[];
|
|
heading?: string;
|
|
renderers?: Record<string, CellRenderer>;
|
|
history?: ViewHistory;
|
|
status?: "ready" | "loading" | "error";
|
|
errorMessage?: string;
|
|
}) {
|
|
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 searchRef = useRef<HTMLInputElement>(null);
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
const target = event.target;
|
|
const typing = target instanceof HTMLElement && (
|
|
target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT"
|
|
);
|
|
if (event.key === "/" && !typing) {
|
|
event.preventDefault();
|
|
searchRef.current?.focus();
|
|
searchRef.current?.select();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
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),
|
|
}));
|
|
|
|
const filteredOut = status === "ready" && rows.length > 0 && visible.length === 0;
|
|
const empty = rows.length === 0 ? "No records." : "No records match this view.";
|
|
|
|
return (
|
|
<section id="records" aria-labelledby="records-heading" className="mx-auto min-w-0 max-w-7xl px-4 py-14 sm:px-6">
|
|
<h2 id="records-heading" className="mb-6 text-2xl">{heading}</h2>
|
|
{status === "loading" ? <p role="status" aria-busy="true">Loading records.</p> : null}
|
|
{status === "error" ? <p role="alert">{errorMessage}</p> : null}
|
|
{status === "ready" ? (
|
|
<form
|
|
className="mb-4 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
commit({ q: draft, sort, facets: view.facets });
|
|
}}
|
|
>
|
|
<label className="flex min-w-0 w-full flex-1 items-center gap-2 text-sm sm:w-auto">
|
|
<span className="sr-only">Search records</span>
|
|
<input
|
|
ref={searchRef}
|
|
aria-label="Search records"
|
|
aria-describedby="search-hint"
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Escape") return;
|
|
event.preventDefault();
|
|
if (draft || view.q) {
|
|
setDraft("");
|
|
if (view.q) commit({ q: "", sort, facets: view.facets });
|
|
} else {
|
|
searchRef.current?.blur();
|
|
}
|
|
}}
|
|
className="h-10 w-full rounded-[0.625rem] border border-border bg-card px-3 text-sm sm:max-w-md"
|
|
/>
|
|
</label>
|
|
<button type="submit" className="btn">Search</button>
|
|
<p id="search-hint" className="text-xs text-muted">Press / to search. Escape clears.</p>
|
|
</form>
|
|
) : null}
|
|
{status === "ready" ? 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>
|
|
);
|
|
}) : null}
|
|
{filteredOut ? (
|
|
<p className="mb-3 text-sm text-muted">
|
|
<button
|
|
type="button"
|
|
className="underline"
|
|
onClick={() => commit({ q: "", facets: {}, sort: baseSort })}
|
|
>
|
|
Clear view
|
|
</button>
|
|
</p>
|
|
) : null}
|
|
{status === "ready" ? (
|
|
<ResultsTable
|
|
columns={tableColumns}
|
|
rows={visible}
|
|
rowKey={(row) => row.id}
|
|
sort={sort}
|
|
onSort={(key) => commit({ q: view.q, facets: view.facets, sort: nextSort(sort, key) })}
|
|
empty={empty}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|