fix(web): objects-table a11y — real-link rows, pill focus ring, announced load/error (#62)

This commit is contained in:
2026-06-08 19:07:00 +02:00
parent 0def81ab42
commit da3e078fbc
2 changed files with 70 additions and 12 deletions
+53 -2
View File
@@ -1,8 +1,8 @@
import { beforeEach, expect, test } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Route, Routes } from "react-router-dom";
import { delay, http, HttpResponse } from "msw";
import { Outlet, Route, Routes } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
@@ -24,6 +24,24 @@ function tree() {
);
}
function nestedTree() {
return (
<Routes>
<Route
path="/objects"
element={
<>
<ObjectsTable />
<Outlet />
</>
}
>
<Route path=":id" element={<div>detail pane</div>} />
</Route>
</Routes>
);
}
/** Capture the query string of every objects request so assertions can inspect URL → request flow. */
function captureRequests() {
const calls: URLSearchParams[] = [];
@@ -134,3 +152,36 @@ test("clicking a row deep-links to /objects/:id preserving the query string", as
expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
});
test("the object number cell is a real link", async () => {
renderApp(tree(), { route: "/objects" });
expect(await screen.findByRole("link", { name: "LM-0042" })).toBeInTheDocument();
});
test("the selected row's link is marked aria-current=page", async () => {
const first = objectsPage.items[0];
renderApp(nestedTree(), { route: `/objects/${first.id}` });
const link = await screen.findByRole("link", { name: first.object_number });
expect(link).toHaveAttribute("aria-current", "page");
const other = await screen.findByRole("link", { name: objectsPage.items[1].object_number });
expect(other).not.toHaveAttribute("aria-current");
});
test("the table is marked aria-busy while loading", async () => {
server.use(
http.get("/api/admin/objects", async () => {
await delay(50);
return HttpResponse.json(objectsPage);
}),
);
renderApp(tree(), { route: "/objects" });
expect(screen.getByRole("table")).toHaveAttribute("aria-busy", "true");
await screen.findByRole("link", { name: "LM-0042" });
expect(screen.getByRole("table")).not.toHaveAttribute("aria-busy");
});
test("a failed objects fetch is announced via role=alert", async () => {
server.use(http.get("/api/admin/objects", () => new HttpResponse(null, { status: 500 })));
renderApp(tree(), { route: "/objects" });
expect(await screen.findByRole("alert")).toHaveTextContent(/could not load/i);
});
+17 -10
View File
@@ -6,6 +6,7 @@ import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
import type { components } from "../api/schema";
import { useObjectsPage } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { focusRing } from "../lib/focus-ring";
import { useConfig } from "../config/config-context";
import { VisibilityBadge } from "./visibility-badge";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -170,7 +171,7 @@ export function ObjectsTable() {
type="button"
aria-pressed={active}
onClick={() => setVisibility(value)}
className={`rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`}
className={`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`}
>
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
</button>
@@ -220,7 +221,7 @@ export function ObjectsTable() {
body = (
<tbody>
<tr>
<td colSpan={6} className="px-3 py-6 text-center text-sm text-destructive">
<td colSpan={6} role="alert" className="px-3 py-6 text-center text-sm text-destructive">
{t("objects.loadError")}
</td>
</tr>
@@ -246,18 +247,21 @@ export function ObjectsTable() {
return (
<tr
key={object.id}
role="link"
tabIndex={0}
aria-selected={selected}
onClick={() => navigate(`/objects/${object.id}?${params}`)}
onKeyDown={(event) => {
if (event.key === "Enter") navigate(`/objects/${object.id}?${params}`);
}}
className={`cursor-pointer border-b text-sm ${
selected ? "bg-primary/10" : "hover:bg-muted"
}`}
>
<td className="px-3 py-2 text-muted-foreground">{object.object_number}</td>
<td className="px-3 py-2 text-muted-foreground">
<Link
to={`/objects/${object.id}?${params}`}
aria-current={selected ? "page" : undefined}
onClick={(event) => event.stopPropagation()}
className={`${focusRing} rounded-sm hover:underline`}
>
{object.object_number}
</Link>
</td>
<td className="px-3 py-2 font-medium">{object.object_name}</td>
<td className="px-3 py-2">
<VisibilityBadge visibility={object.visibility} />
@@ -276,7 +280,10 @@ export function ObjectsTable() {
<div className="flex h-full flex-col">
{toolbar}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<table className="w-full border-collapse" aria-busy={isLoading || undefined}>
<caption className="sr-only" aria-live="polite">
{isLoading ? t("common.loading") : t("objects.tableLabel")}
</caption>
{columns}
{body}
</table>