From da3e078fbc770e10b7f015ec9dda141dd200cc34 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 19:07:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20objects-table=20a11y=20=E2=80=94=20?= =?UTF-8?q?real-link=20rows,=20pill=20focus=20ring,=20announced=20load/err?= =?UTF-8?q?or=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/objects/objects-table.test.tsx | 55 +++++++++++++++++++++++++- web/src/objects/objects-table.tsx | 27 ++++++++----- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/web/src/objects/objects-table.test.tsx b/web/src/objects/objects-table.test.tsx index 1f3ea6e..0565ab0 100644 --- a/web/src/objects/objects-table.test.tsx +++ b/web/src/objects/objects-table.test.tsx @@ -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 ( + + + + + + } + > + detail pane} /> + + + ); +} + /** 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); +}); diff --git a/web/src/objects/objects-table.tsx b/web/src/objects/objects-table.tsx index 619f671..cc67458 100644 --- a/web/src/objects/objects-table.tsx +++ b/web/src/objects/objects-table.tsx @@ -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}`)} @@ -220,7 +221,7 @@ export function ObjectsTable() { body = ( - + {t("objects.loadError")} @@ -246,18 +247,21 @@ export function ObjectsTable() { return ( 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" }`} > - {object.object_number} + + event.stopPropagation()} + className={`${focusRing} rounded-sm hover:underline`} + > + {object.object_number} + + {object.object_name} @@ -276,7 +280,10 @@ export function ObjectsTable() {
{toolbar}
- +
+ {columns} {body}
+ {isLoading ? t("common.loading") : t("objects.tableLabel")} +