` rows and the error a plain `` — neither is announced (WCAG 4.1.3).
+- Add `aria-busy={isLoading || undefined}` to the ``.
+- Add an `sr-only` live `` that announces loading and settles to the table name (also gives the
+ table an accessible name):
+```tsx
+
+
+ {isLoading ? t("common.loading") : t("objects.tableLabel")}
+
+ {columns}
+ {body}
+
+```
+- Add `role="alert"` to the error `` (`:223`) so a load failure is announced assertively:
+```tsx
+
+ {t("objects.loadError")}
+
+```
+
+### 4. `objects/object-detail-drawer.tsx` — name the drawer dialog
+The Base UI drawer (a modal dialog) has no accessible name. `DrawerContent` spreads props onto the Popup,
+so pass an `aria-label`:
+```tsx
+
+```
+(No change to `components/ui/drawer.tsx`.)
+
+### 5. Last untranslated strings
+- `objects/options-combobox.tsx` (`:47-52`): add `import { useTranslation } from "react-i18next";` +
+ `const { t } = useTranslation();`, then `aria-label={t("common.clear")}` on `ComboboxClear`,
+ `aria-label={t("common.open")}` on `ComboboxTrigger`, and `{t("common.noMatches")} `
+ (`common.noMatches` already exists).
+- `shell/breadcrumb.tsx` (`:10`): add `useTranslation`; ``.
+
+### i18n (en + sv parity — 5 new keys)
+`common.noMatches` and `common.loading` already exist. New keys:
+| key | en | sv |
+|-----|----|----|
+| `common.clear` | Clear | Rensa |
+| `common.open` | Open | Öppna |
+| `nav.breadcrumb` | Breadcrumb | Brödsmulor |
+| `objects.detailTitle` | Object detail | Objektdetalj |
+| `objects.tableLabel` | Objects | Objekt |
+
+## Data flow / accessibility
+No data-flow changes. Tab order in the objects table becomes: filter input → visibility pills (now with
+ring) → New button → each row's object-number link → pagination. The selected row's link carries
+`aria-current="page"`. Loading politely announces via the caption; a load error asserts via the alert
+cell. The drawer and breadcrumb gain accessible names; every combobox control is named in the active
+locale.
+
+## Error handling / edges
+- `aria-busy={isLoading || undefined}` omits the attribute when not loading (no `aria-busy="false"` noise).
+- `event.stopPropagation()` on the row link means a plain click on the number navigates once (link), not
+ twice; modifier/middle clicks open a new tab natively (React Router ` ` respects them).
+- `aria-current` is omitted (not `"false"`) when the row isn't selected.
+- The `` is `sr-only` — no visual change to the table.
+- `useId()` ids are SSR/StrictMode stable and unique per instance.
+
+## Testing
+- **`label-editor`**: render two `LabelEditor`s together; assert their inputs have **different** ids and
+ each `` is associated with its own input (e.g. `getAllByLabelText` returns two distinct nodes).
+- **`objects-table`**: (a) a data row exposes a `link` named by the object number
+ (`getByRole("link", { name: })`); (b) the row matching the selected `:id` has the link
+ with `aria-current="page"`; (c) clicking a row still deep-links (existing test stays green); (d) the
+ loading state sets `aria-busy` on the table; (e) an errored fetch renders `role="alert"`. Update any
+ existing assertion that referenced `aria-selected`/the row as a link.
+- **`options-combobox`**: the clear/open controls are named via `t()` and the empty text is translated
+ (assert the English defaults render; the parity test guards sv).
+- **`breadcrumb`**: the `` accessible name comes from `t("nav.breadcrumb")`.
+- **`object-detail-drawer`**: the open drawer dialog has an accessible name (`getByRole("dialog", { name })`
+ or equivalent for the Base UI popup).
+- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; en/sv parity (5 new keys,
+ guarded by the #60 parity test); no codename; no new dependency. `focusRing` is token-based so
+ `check:colors` stays green.
+
+## Acceptance criteria
+1. `LabelEditor` uses `useId()`; two instances never share an input id.
+2. Objects-table data rows expose a real ` ` (object-number cell) with `aria-current="page"` on the
+ selected row; no `role="link"`/`aria-selected` on the ``; whole-row click still navigates; the
+ filter pills show the keyboard focus ring.
+3. The table sets `aria-busy` while loading and exposes a polite live caption; a load error renders
+ `role="alert"`.
+4. The object-detail drawer dialog and the breadcrumb `` have accessible names; the combobox
+ clear/open/empty strings are translated.
+5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported; en/sv parity (5 new
+ keys); no codename; no new dependency.
+
+## Out of scope → follow-ups
+- The combobox wrapper's raw palette utilities (`text-neutral-*`, `bg-indigo-50`, `bg-white`) inside
+ `components/ui/combobox.tsx`, and the segmented-control extraction → design-kit issue **#66**.
+- A full ARIA grid for the objects table (sortable/selectable grid semantics) — the navigation-table +
+ real-link pattern is the correct, simpler choice here.
+- Automated axe/CI a11y scanning.
diff --git a/web/src/components/label-editor.test.tsx b/web/src/components/label-editor.test.tsx
index d8f6ae3..188d0b4 100644
--- a/web/src/components/label-editor.test.tsx
+++ b/web/src/components/label-editor.test.tsx
@@ -85,3 +85,16 @@ test("no hint when only the default-language label exists", () => {
renderApp( );
expect(screen.queryByText(/other languages/i)).not.toBeInTheDocument();
});
+
+test("each LabelEditor instance gets a unique input id", () => {
+ renderApp(
+ <>
+ {}} />
+ {}} />
+ >,
+ );
+ const inputs = screen.getAllByLabelText(/label/i);
+ expect(inputs).toHaveLength(2);
+ expect(inputs[0].id).not.toBe("");
+ expect(inputs[0].id).not.toBe(inputs[1].id);
+});
diff --git a/web/src/components/label-editor.tsx b/web/src/components/label-editor.tsx
index 81dc0bd..23511ac 100644
--- a/web/src/components/label-editor.tsx
+++ b/web/src/components/label-editor.tsx
@@ -1,3 +1,4 @@
+import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
@@ -19,6 +20,7 @@ export function LabelEditor({
onChange: (labels: LabelInput[]) => void;
}) {
const { t } = useTranslation();
+ const inputId = useId();
const { default_language } = useConfig();
const current = value.find((l) => l.lang === default_language)?.label ?? "";
@@ -32,8 +34,8 @@ export function LabelEditor({
return (
-
{t("labels.label")}
-
set(e.target.value)} />
+
{t("labels.label")}
+
set(e.target.value)} />
{hasOtherLanguages && (
{t("labels.otherLanguages")}
)}
diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json
index 1458c67..f4f2b59 100644
--- a/web/src/i18n/en.json
+++ b/web/src/i18n/en.json
@@ -1,8 +1,8 @@
{
- "common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches", "language": "Language", "skipToContent": "Skip to content" },
- "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" },
+ "common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches", "language": "Language", "skipToContent": "Skip to content", "clear": "Clear", "open": "Open" },
+ "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar", "breadcrumb": "Breadcrumb" },
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server", "sessionExpired": "Your session expired — please sign in again.", "signingOut": "Signing out…" },
- "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" },
+ "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)", "detailTitle": "Object detail", "tableLabel": "Objects" },
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility" },
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "createdButFieldRejected": "Object created, but a field was rejected — fix it below.", "flexibleHeading": "Catalogue fields", "saving": "Saving…", "createAnother": "Save & create another", "minCount": "Must be at least 1", "fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }, "unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" } },
diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json
index 4a2bbab..098deb7 100644
--- a/web/src/i18n/sv.json
+++ b/web/src/i18n/sv.json
@@ -1,8 +1,8 @@
{
- "common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar", "language": "Språk", "skipToContent": "Hoppa till innehåll" },
- "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" },
+ "common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar", "language": "Språk", "skipToContent": "Hoppa till innehåll", "clear": "Rensa", "open": "Öppna" },
+ "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet", "breadcrumb": "Brödsmulor" },
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern", "sessionExpired": "Din session har gått ut — logga in igen.", "signingOut": "Loggar ut…" },
- "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" },
+ "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)", "detailTitle": "Objektdetalj", "tableLabel": "Objekt" },
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet" },
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "createdButFieldRejected": "Föremålet skapades, men ett fält avvisades — åtgärda nedan.", "flexibleHeading": "Katalogfält", "saving": "Sparar…", "createAnother": "Spara & skapa ny", "minCount": "Måste vara minst 1", "fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }, "unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" } },
diff --git a/web/src/objects/object-detail-drawer.tsx b/web/src/objects/object-detail-drawer.tsx
index 6e55b49..d9bd52e 100644
--- a/web/src/objects/object-detail-drawer.tsx
+++ b/web/src/objects/object-detail-drawer.tsx
@@ -26,7 +26,7 @@ export function ObjectDetailDrawer({
}}
swipeDirection="right"
>
-
+
+
+
+
+ >
+ }
+ >
+ 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}
-
+
+
+ {isLoading ? t("common.loading") : t("objects.tableLabel")}
+
{columns}
{body}
diff --git a/web/src/objects/options-combobox.test.tsx b/web/src/objects/options-combobox.test.tsx
index 315b03f..767d096 100644
--- a/web/src/objects/options-combobox.test.tsx
+++ b/web/src/objects/options-combobox.test.tsx
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import "../i18n";
import { OptionsCombobox, type Option } from "./options-combobox";
const options: Option[] = [
@@ -49,6 +50,11 @@ describe("OptionsCombobox", () => {
expect(screen.getByDisplayValue("Wood")).toBeInTheDocument();
});
+ it("gives the open control a translated accessible name", () => {
+ setup();
+ expect(screen.getByRole("button", { name: /open/i })).toBeInTheDocument();
+ });
+
it("clears the selection when the Clear button is clicked", async () => {
const user = userEvent.setup();
const { onChange } = setup("t1");
diff --git a/web/src/objects/options-combobox.tsx b/web/src/objects/options-combobox.tsx
index 17c31ae..698d0ee 100644
--- a/web/src/objects/options-combobox.tsx
+++ b/web/src/objects/options-combobox.tsx
@@ -1,3 +1,5 @@
+import { useTranslation } from "react-i18next";
+
import type { components } from "../api/schema";
import { labelText } from "../lib/labels";
import {
@@ -32,6 +34,7 @@ export function OptionsCombobox({
lang: string;
placeholder: string;
}) {
+ const { t } = useTranslation();
const selected = options.find((o) => o.id === value) ?? null;
return (
@@ -44,12 +47,12 @@ export function OptionsCombobox({
>
-
-
+
+
- No matches.
+ {t("common.noMatches")}
{(option: Option) => (
diff --git a/web/src/shell/breadcrumb.test.tsx b/web/src/shell/breadcrumb.test.tsx
index 67d090d..c3703c5 100644
--- a/web/src/shell/breadcrumb.test.tsx
+++ b/web/src/shell/breadcrumb.test.tsx
@@ -26,6 +26,7 @@ test("renders the trail with a link on non-leaf crumbs", async () => {
const link = await screen.findByRole("link", { name: "Objects" });
expect(link).toHaveAttribute("href", "/objects");
expect(screen.getByText("LM-0042")).toBeInTheDocument();
+ expect(screen.getByRole("navigation", { name: /breadcrumb/i })).toBeInTheDocument();
});
test("a nested route sets the header breadcrumb inside AppShell", async () => {
diff --git a/web/src/shell/breadcrumb.tsx b/web/src/shell/breadcrumb.tsx
index 68ed412..95c3f1b 100644
--- a/web/src/shell/breadcrumb.tsx
+++ b/web/src/shell/breadcrumb.tsx
@@ -1,13 +1,15 @@
import { Fragment } from "react";
+import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useBreadcrumbTrail } from "./breadcrumb-context";
export function Breadcrumb() {
+ const { t } = useTranslation();
const trail = useBreadcrumbTrail();
if (trail.length === 0) return
;
return (
-
+
{trail.map((item, i) => {
const last = i === trail.length - 1;
return (