fix(web): objects-table a11y — real-link rows, pill focus ring, announced load/error (#62)
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import { beforeEach, expect, test } from "vitest";
|
import { beforeEach, expect, test } from "vitest";
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { http, HttpResponse } from "msw";
|
import { delay, http, HttpResponse } from "msw";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { Outlet, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import { server } from "../test/server";
|
import { server } from "../test/server";
|
||||||
import { renderApp } from "../test/render";
|
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. */
|
/** Capture the query string of every objects request so assertions can inspect URL → request flow. */
|
||||||
function captureRequests() {
|
function captureRequests() {
|
||||||
const calls: URLSearchParams[] = [];
|
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();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
|
|||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
import { useObjectsPage } from "../api/queries";
|
import { useObjectsPage } from "../api/queries";
|
||||||
import { useDebouncedValue } from "../lib/use-debounced-value";
|
import { useDebouncedValue } from "../lib/use-debounced-value";
|
||||||
|
import { focusRing } from "../lib/focus-ring";
|
||||||
import { useConfig } from "../config/config-context";
|
import { useConfig } from "../config/config-context";
|
||||||
import { VisibilityBadge } from "./visibility-badge";
|
import { VisibilityBadge } from "./visibility-badge";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
@@ -170,7 +171,7 @@ export function ObjectsTable() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
onClick={() => setVisibility(value)}
|
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}`)}
|
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
|
||||||
</button>
|
</button>
|
||||||
@@ -220,7 +221,7 @@ export function ObjectsTable() {
|
|||||||
body = (
|
body = (
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<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")}
|
{t("objects.loadError")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -246,18 +247,21 @@ export function ObjectsTable() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={object.id}
|
key={object.id}
|
||||||
role="link"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={selected}
|
|
||||||
onClick={() => navigate(`/objects/${object.id}?${params}`)}
|
onClick={() => navigate(`/objects/${object.id}?${params}`)}
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") navigate(`/objects/${object.id}?${params}`);
|
|
||||||
}}
|
|
||||||
className={`cursor-pointer border-b text-sm ${
|
className={`cursor-pointer border-b text-sm ${
|
||||||
selected ? "bg-primary/10" : "hover:bg-muted"
|
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 font-medium">{object.object_name}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<VisibilityBadge visibility={object.visibility} />
|
<VisibilityBadge visibility={object.visibility} />
|
||||||
@@ -276,7 +280,10 @@ export function ObjectsTable() {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{toolbar}
|
{toolbar}
|
||||||
<div className="flex-1 overflow-auto">
|
<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}
|
{columns}
|
||||||
{body}
|
{body}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user