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
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue