diff --git a/web/src/components/filtered-record-list.test.tsx b/web/src/components/filtered-record-list.test.tsx
new file mode 100644
index 0000000..361c273
--- /dev/null
+++ b/web/src/components/filtered-record-list.test.tsx
@@ -0,0 +1,51 @@
+import { expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { renderApp } from "../test/render";
+import { FilteredRecordList } from "./filtered-record-list";
+import { labelText } from "../lib/labels";
+
+type Rec = { id: string; labels: { lang: string; label: string }[] };
+const recs: Rec[] = [
+ { id: "a", labels: [{ lang: "en", label: "Alpha" }] },
+ { id: "b", labels: [{ lang: "en", label: "Beta" }] },
+];
+const row = (r: Rec) =>
{labelText(r.labels, "en")};
+
+test("filtering narrows the rendered rows", async () => {
+ renderApp(
+ ,
+ );
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
+ expect(screen.getByText("Beta")).toBeInTheDocument();
+ await userEvent.type(screen.getByLabelText(/filter/i), "alph");
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
+ expect(screen.queryByText("Beta")).toBeNull();
+});
+
+test("empty records show the empty text", () => {
+ renderApp(
+ ,
+ );
+ expect(screen.getByText("EmptyMsg")).toBeInTheDocument();
+});
+
+test("non-empty records with a non-matching filter show no-matches", async () => {
+ renderApp(
+ ,
+ );
+ await userEvent.type(screen.getByLabelText(/filter/i), "zzz");
+ expect(screen.getByText(/no matches/i)).toBeInTheDocument();
+});
+
+test("an error shows the load-error text", () => {
+ renderApp(
+ ,
+ );
+ expect(screen.getByText("LoadErr")).toBeInTheDocument();
+});
diff --git a/web/src/components/filtered-record-list.tsx b/web/src/components/filtered-record-list.tsx
new file mode 100644
index 0000000..c1f74c9
--- /dev/null
+++ b/web/src/components/filtered-record-list.tsx
@@ -0,0 +1,68 @@
+import { Fragment, useState, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { components } from "../api/schema";
+import { byLabel } from "../lib/sort";
+import { labelText } from "../lib/labels";
+import { Input } from "@/components/ui/input";
+import { ListSkeleton } from "@/components/ui/skeletons";
+
+type LabelView = components["schemas"]["LabelView"];
+
+/** Filterable, alphabetically-sorted list of labelled records with the standard
+ * loading / error / empty / no-matches states. The filter input stays visible
+ * during load (matching the prior page behaviour). */
+export function FilteredRecordList({
+ records,
+ lang,
+ isLoading,
+ isError,
+ loadErrorText,
+ emptyText,
+ renderRow,
+}: {
+ records: T[] | undefined;
+ lang: string;
+ isLoading: boolean;
+ isError: boolean;
+ loadErrorText: string;
+ emptyText: string;
+ renderRow: (record: T) => ReactNode;
+}) {
+ const { t } = useTranslation();
+ const [filter, setFilter] = useState("");
+
+ const q = filter.trim().toLowerCase();
+ const rows = [...(records ?? [])]
+ .filter((r) => !q || labelText(r.labels, lang).toLowerCase().includes(q))
+ .sort(byLabel(lang));
+
+ return (
+ <>
+
+ setFilter(e.target.value)}
+ />
+
+ {isLoading ? (
+
+ ) : (
+
+ {isError && - {loadErrorText}
}
+ {!isError && records?.length === 0 && (
+ - {emptyText}
+ )}
+ {!isError && records && records.length > 0 && rows.length === 0 && (
+ - {t("common.noMatches")}
+ )}
+ {rows.map((r) => (
+ {renderRow(r)}
+ ))}
+
+ )}
+ >
+ );
+}