Add keyboard search and the empty, loading, and error states.
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
This commit is contained in:
parent
0cbc7baf49
commit
85ec9fa867
7 changed files with 199 additions and 12 deletions
11
ui/app/error.tsx
Normal file
11
ui/app/error.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-14 sm:px-6" role="alert">
|
||||
<h1 className="text-2xl">This page could not be loaded.</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-muted">{error.message || "Records could not be loaded."}</p>
|
||||
<button type="button" className="btn mt-6" onClick={() => reset()}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
ui/app/loading.tsx
Normal file
7
ui/app/loading.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<p className="mx-auto max-w-7xl px-4 py-14 text-sm text-muted sm:px-6" role="status" aria-busy="true">
|
||||
Loading records.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
100
ui/components/RecordBrowser.states.test.tsx
Normal file
100
ui/components/RecordBrowser.states.test.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mapBrowserRow, type ColumnDef } from "@/lib/browser-row";
|
||||
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"],
|
||||
),
|
||||
];
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function render(props: Partial<ComponentProps<typeof RecordBrowser>> = {}) {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
await act(async () => {
|
||||
root?.render(<RecordBrowser rows={rows} columns={columns} history={quietHistory()} {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
function quietHistory() {
|
||||
return { read: () => "", push: () => undefined, subscribe: () => () => undefined };
|
||||
}
|
||||
|
||||
describe("record browser states", () => {
|
||||
it("shows loading and error without the table", async () => {
|
||||
await render({ status: "loading" });
|
||||
expect(document.querySelector("[role='status']")?.textContent).toBe("Loading records.");
|
||||
expect(document.querySelector("table")).toBeNull();
|
||||
|
||||
await act(async () => root?.unmount());
|
||||
await render({ status: "error", errorMessage: "The snapshot is unreadable." });
|
||||
expect(document.querySelector("[role='alert']")?.textContent).toBe("The snapshot is unreadable.");
|
||||
expect(document.querySelector("table")).toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes an empty catalog from a search that matches nothing", async () => {
|
||||
await render({ rows: [] });
|
||||
expect(document.body.textContent).toContain("No records.");
|
||||
expect(document.body.textContent).not.toContain("Clear view");
|
||||
|
||||
await act(async () => root?.unmount());
|
||||
await render();
|
||||
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, "missing");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.form?.requestSubmit();
|
||||
});
|
||||
expect(document.body.textContent).toContain("No records match this view.");
|
||||
const clear = [...document.querySelectorAll("button")].find((node) => node.textContent === "Clear view");
|
||||
if (!(clear instanceof HTMLButtonElement)) throw new Error("missing clear");
|
||||
await act(async () => {
|
||||
clear.click();
|
||||
});
|
||||
expect(document.body.textContent).toContain("Alpha");
|
||||
});
|
||||
|
||||
it("focuses search on slash and clears it on escape", async () => {
|
||||
await render();
|
||||
const input = document.querySelector("input[aria-label='Search records']");
|
||||
if (!(input instanceof HTMLInputElement)) throw new Error("missing search");
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true }));
|
||||
});
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
await act(async () => {
|
||||
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setValue?.call(input, "draft");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
});
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
|
|
@ -25,12 +25,16 @@ export function RecordBrowser({
|
|||
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]);
|
||||
|
|
@ -38,6 +42,7 @@ export function RecordBrowser({
|
|||
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 });
|
||||
|
|
@ -52,6 +57,23 @@ export function RecordBrowser({
|
|||
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);
|
||||
|
|
@ -72,28 +94,48 @@ export function RecordBrowser({
|
|||
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 max-w-7xl px-4 py-14 sm:px-6">
|
||||
<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-wrap items-center gap-2"
|
||||
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 flex-1 items-center gap-2 text-sm">
|
||||
<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)}
|
||||
className="h-10 w-full max-w-md rounded-[0.625rem] border border-border bg-card px-3 text-sm"
|
||||
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>
|
||||
{facetKeys.map((key) => {
|
||||
) : null}
|
||||
{status === "ready" ? facetKeys.map((key) => {
|
||||
const values = facetValues(rows, key);
|
||||
if (values.length === 0) return null;
|
||||
const selected = view.facets[key] ?? [];
|
||||
|
|
@ -113,15 +155,28 @@ export function RecordBrowser({
|
|||
))}
|
||||
</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="No records match this view."
|
||||
empty={empty}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function SiteHeader({ dataspaces }: { dataspaces: DataspaceNav[] }) {
|
|||
const active = dataspaces.find((item) => path === `/${item.id}` || path.startsWith(`/${item.id}/`)) ?? dataspaces.find((item) => item.id === "systems-thinking") ?? dataspaces[0];
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/75">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4 sm:gap-6 sm:px-6">
|
||||
<div className="mx-auto flex min-h-14 max-w-7xl flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2 sm:h-14 sm:flex-nowrap sm:gap-6 sm:px-6 sm:py-0">
|
||||
<a href="/" className="inline-flex h-10 shrink-0 items-center rounded-lg font-semibold tracking-tight" aria-label="Content Explorer home">
|
||||
<span className="text-[15px]">Content Explorer</span>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ state_hub_workstream_id: "55c1d92f-fe1a-552a-998d-2d6905fb6b99"
|
|||
|
||||
# UI extraction and experience skeleton
|
||||
|
||||
Deliver the strongest OnCo-style interaction patterns on generic data. CONTENT-WP-0000 and CONTENT-WP-0001 are finished, so this plan is eligible. T01 through T04 have landed under `ui/`. T04 keeps search, facet selection, and sort in the page URL, and back and forward restore that view. T05 is next. See `workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md`.
|
||||
Deliver the strongest OnCo-style interaction patterns on generic data. CONTENT-WP-0000 and CONTENT-WP-0001 are finished, so this plan is eligible. T01 through T05 have landed under `ui/`. T05 adds keyboard search, empty, loading, and error states, and a layout that wraps on a narrow screen. T06 is next. See `workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md`.
|
||||
|
||||
## Work packages
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ state_hub_task_id: "bacd58df-4834-5157-834e-26da4c6ba470"
|
|||
|
||||
```task
|
||||
id: CONTENT-WP-0002-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "bbbc39cd-c47f-541d-b43d-df9e7ff646c3"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -78,4 +78,18 @@ Checks on 2026-10-13, after T04:
|
|||
- `npm run typecheck`, `scripts/check_ui_boundary.py`, fixture validation, and the contract and ingest tests passed.
|
||||
- `next dev` on `127.0.0.1:3400` rendered `/systems-thinking/concept/` with the search field and the tag `regulation`. The server was stopped. No desktop browser was available, so back and forward were exercised in the history test rather than in a graphical browser.
|
||||
|
||||
Not done: keyboard search and empty/error/loading states, the T06 gate for both dataspaces across search and filter, the formal import gate, the provenance gate, or keyboard and small-screen smoke. A facet key other than `tags` still has no values in these fixtures.
|
||||
Not done after T04: keyboard search and empty/error/loading states, the T06 gate for both dataspaces across search and filter, the formal import gate, the provenance gate, or keyboard and small-screen smoke. A facet key other than `tags` still has no values in these fixtures.
|
||||
|
||||
## T05 — keyboard search, empty, loading, error, and narrow layout
|
||||
|
||||
`/` focuses the record search when the key press is not already in a field. Escape clears the draft, and clears a submitted query. An empty catalog says "No records." A search or facet that matches nothing says "No records match this view." and offers Clear view. `status="loading"` and `status="error"` replace the table with a status or an alert. Route changes use `app/loading.tsx` and `app/error.tsx`.
|
||||
|
||||
The search form stacks in one column and the header wraps instead of staying on one fixed row. The table still scrolls sideways when the columns are wider than the screen.
|
||||
|
||||
Checks on 2026-10-13, after T05:
|
||||
|
||||
- `npm test` in `ui/`: 17 tests passed. The new tests cover loading, error, both empty messages, Clear view, `/` focus, and Escape.
|
||||
- `npm run typecheck`, `scripts/check_ui_boundary.py`, fixture validation, and the contract and ingest tests passed.
|
||||
- `next dev` on `127.0.0.1:3400` rendered `/systems-thinking/concept/` with the search hint and the narrow-layout classes. The server was stopped. No graphical browser was available, so a narrow viewport was not walked. That smoke stays with T09.
|
||||
|
||||
Not done: the T06 gate that browse, search, filter, sort, and detail pass together for both dataspaces, the formal import gate, the provenance gate, or the keyboard and small-screen smoke. A facet key other than `tags` still has no values in these fixtures.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue