diff --git a/ui/app/error.tsx b/ui/app/error.tsx new file mode 100644 index 0000000..b9b17e8 --- /dev/null +++ b/ui/app/error.tsx @@ -0,0 +1,11 @@ +"use client"; + +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+

This page could not be loaded.

+

{error.message || "Records could not be loaded."}

+ +
+ ); +} diff --git a/ui/app/loading.tsx b/ui/app/loading.tsx new file mode 100644 index 0000000..166fe5a --- /dev/null +++ b/ui/app/loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +

+ Loading records. +

+ ); +} diff --git a/ui/components/RecordBrowser.states.test.tsx b/ui/components/RecordBrowser.states.test.tsx new file mode 100644 index 0000000..a812132 --- /dev/null +++ b/ui/components/RecordBrowser.states.test.tsx @@ -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> = {}) { + const host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root?.render(); + }); +} + +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(""); + }); +}); diff --git a/ui/components/RecordBrowser.tsx b/ui/components/RecordBrowser.tsx index 57439b7..02479dd 100644 --- a/ui/components/RecordBrowser.tsx +++ b/ui/components/RecordBrowser.tsx @@ -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; 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(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 ( -
+

{heading}

+ {status === "loading" ?

Loading records.

: null} + {status === "error" ?

{errorMessage}

: null} + {status === "ready" ? (
{ event.preventDefault(); commit({ q: draft, sort, facets: view.facets }); }} > -
); } diff --git a/ui/components/SiteHeader.tsx b/ui/components/SiteHeader.tsx index 8222338..695328b 100644 --- a/ui/components/SiteHeader.tsx +++ b/ui/components/SiteHeader.tsx @@ -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 (
-
+
Content Explorer diff --git a/workplans/CONTENT-WP-0002-ui-extraction.md b/workplans/CONTENT-WP-0002-ui-extraction.md index c1f68a0..3fe4e73 100644 --- a/workplans/CONTENT-WP-0002-ui-extraction.md +++ b/workplans/CONTENT-WP-0002-ui-extraction.md @@ -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" ``` diff --git a/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md b/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md index ec22cbd..34bbad7 100644 --- a/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md +++ b/workplans/evidence/CONTENT-WP-0002-ImplementationNotes.md @@ -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.