merge: accessibility defect bundle — label-id, table rows, drawer/breadcrumb names, announced states (#62)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
# Accessibility Defect Bundle — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix five remaining a11y defects — label-id collision, unnamed drawer/breadcrumb, untranslated combobox strings (Task 1); invalid table-row semantics, missing pill focus ring, unannounced table load/error states (Task 2).
|
||||
|
||||
**Architecture:** Task 1 is a labelling/i18n cluster across four small components plus 5 new i18n keys. Task 2 reworks the objects-table data rows to use a real `<Link>` with `aria-current`, restores `focusRing` on the filter pills, and adds `aria-busy` + a live `<caption>` + `role="alert"` for load/error announcement.
|
||||
|
||||
**Tech Stack:** React 19 + TS + pnpm, React Router 7, Base UI, react-i18next, Vitest 4 (jsdom) + RTL + MSW.
|
||||
|
||||
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; app source double-quote+semicolon; `components/ui/*` untouched; token classes only (`focusRing` is token-based). Run a single test pass.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-08-a11y-defect-bundle-design.md`
|
||||
|
||||
**Key facts:**
|
||||
- Existing i18n keys: `common.noMatches` ("No matches"), `common.loading` ("Loading"), `nav.objects`, `objects.loadError` ("Could not load objects"), `actions.closeDetail`. NEW keys to add (en/sv): `common.clear`, `common.open`, `nav.breadcrumb`, `objects.detailTitle`, `objects.tableLabel`.
|
||||
- `lib/focus-ring.ts` exports `focusRing` (a class string). Imported elsewhere as `import { focusRing } from "../lib/focus-ring";`.
|
||||
- `components/label-editor.test.tsx`, `objects/options-combobox.test.tsx`, `shell/breadcrumb.test.tsx` exist. `objects/object-detail-drawer.test.tsx` does NOT.
|
||||
- `objects/objects-table.test.tsx`: imports `renderApp`, `objectsPage` (from `../test/fixtures`), `ObjectsTable`, `ObjectDetail`, `i18n`, `Routes`/`Route`. Its `tree()` mounts `ObjectsTable` at `/objects` and `ObjectDetail` at `/objects/:id` as siblings. Fixtures: `objectsPage.items[0]` = `{ object_number: "LM-0042", object_name: "Amphora", … }`, `[1]` = `"LM-0043"`/`"Bronze fibula"`. The "clicking a row deep-links…" test clicks the **name** ("Amphora"), which stays plain text — it survives unchanged.
|
||||
- `combobox.tsx` wrapper: `ComboboxClear`/`ComboboxTrigger` pass `aria-label` through to Base UI; `ComboboxEmpty` renders children. Do NOT modify `components/ui/combobox.tsx`.
|
||||
- `object-detail-drawer.tsx`: `DrawerContent` spreads `...props` onto the Base UI `Drawer.Popup`, so `aria-label` passes through.
|
||||
|
||||
---
|
||||
|
||||
# Task 1: Labelling + i18n cluster (label-editor, combobox, breadcrumb, drawer)
|
||||
|
||||
**Files:** Modify `web/src/i18n/en.json`, `web/src/i18n/sv.json`, `web/src/components/label-editor.tsx`, `web/src/objects/options-combobox.tsx`, `web/src/shell/breadcrumb.tsx`, `web/src/objects/object-detail-drawer.tsx`; tests `web/src/components/label-editor.test.tsx`, `web/src/objects/options-combobox.test.tsx`, `web/src/shell/breadcrumb.test.tsx`.
|
||||
|
||||
- [ ] **Step 1: Add the 5 i18n keys (both locales, parity).** In `web/src/i18n/en.json`, add to the relevant blocks: under `common` → `"clear": "Clear", "open": "Open"`; under `nav` → `"breadcrumb": "Breadcrumb"`; under `objects` → `"detailTitle": "Object detail", "tableLabel": "Objects"`. In `web/src/i18n/sv.json`, the same keys: `common.clear` = `"Rensa"`, `common.open` = `"Öppna"`, `nav.breadcrumb` = `"Brödsmulor"`, `objects.detailTitle` = `"Objektdetalj"`, `objects.tableLabel` = `"Objekt"`. (Valid JSON; mind commas. Place each new key beside its existing siblings in the same nested object.)
|
||||
|
||||
- [ ] **Step 2: `label-editor.tsx` — `useId()`.** Add `useId` to the React import (`import { useId } from "react";`). Inside the component, add `const inputId = useId();` and change the two lines to:
|
||||
```tsx
|
||||
<Label htmlFor={inputId}>{t("labels.label")}</Label>
|
||||
<Input id={inputId} value={current} onChange={(e) => set(e.target.value)} />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `options-combobox.tsx` — translate.** Add `import { useTranslation } from "react-i18next";` and `const { t } = useTranslation();` at the top of the component body. Change:
|
||||
```tsx
|
||||
<ComboboxClear aria-label={t("common.clear")} />
|
||||
<ComboboxTrigger aria-label={t("common.open")} />
|
||||
```
|
||||
and
|
||||
```tsx
|
||||
<ComboboxEmpty>{t("common.noMatches")}</ComboboxEmpty>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `breadcrumb.tsx` — translate the nav label.** Add `import { useTranslation } from "react-i18next";` and `const { t } = useTranslation();` inside `Breadcrumb` (before the `if (trail.length === 0)` guard). Change `<nav aria-label="Breadcrumb" …>` to `<nav aria-label={t("nav.breadcrumb")} …>`.
|
||||
|
||||
- [ ] **Step 5: `object-detail-drawer.tsx` — name the dialog.** Change `<DrawerContent>` to `<DrawerContent aria-label={t("objects.detailTitle")}>` (the `t` from `useTranslation` is already in scope in this file).
|
||||
|
||||
- [ ] **Step 6: Tests.**
|
||||
- **`label-editor.test.tsx`** — append (reuse the file's existing render harness / providers, e.g. `renderApp` or whatever wraps `useConfig`; read the top of the file first):
|
||||
```tsx
|
||||
test("each LabelEditor instance gets a unique input id", () => {
|
||||
renderApp(
|
||||
<>
|
||||
<LabelEditor value={[]} onChange={() => {}} />
|
||||
<LabelEditor value={[]} onChange={() => {}} />
|
||||
</>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
```
|
||||
(If `LabelEditor` needs config context that `renderApp` doesn't provide, mirror the wrapper the existing tests in this file use. Keep existing tests green.)
|
||||
- **`options-combobox.test.tsx`** — append (mirror the file's existing render of `OptionsCombobox`):
|
||||
```tsx
|
||||
test("the clear and open controls and empty text are translated", async () => {
|
||||
// render OptionsCombobox with empty options so the empty state is reachable,
|
||||
// using the same harness the other tests in this file use.
|
||||
// …render…
|
||||
expect(screen.getByRole("button", { name: /open/i })).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
(Adapt to the existing test's setup. The key assertion: the open/clear controls have accessible names from `t()`. If the existing test already opens the popup, also assert `screen.getByText("No matches")`.)
|
||||
- **`breadcrumb.test.tsx`** — append:
|
||||
```tsx
|
||||
test("the breadcrumb nav has a translated accessible name", () => {
|
||||
// render Breadcrumb with a non-empty trail using the file's existing harness
|
||||
// (it needs a BreadcrumbContext provider with a trail).
|
||||
expect(screen.getByRole("navigation", { name: /breadcrumb/i })).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
(Mirror the existing breadcrumb test's provider setup; if the file already renders a trail, just add the `getByRole("navigation", { name })` assertion.)
|
||||
|
||||
- [ ] **Step 7: Verify (vitest ONCE), typecheck, lint:**
|
||||
```bash
|
||||
cd web && pnpm vitest run src/components/label-editor.test.tsx src/objects/options-combobox.test.tsx src/shell/breadcrumb.test.tsx src/i18n && pnpm typecheck && pnpm lint
|
||||
```
|
||||
Expected: green (incl. i18n parity covering the 5 new keys). Keep all existing tests in those files green.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web/src/i18n/en.json web/src/i18n/sv.json web/src/components/label-editor.tsx web/src/objects/options-combobox.tsx web/src/shell/breadcrumb.tsx web/src/objects/object-detail-drawer.tsx web/src/components/label-editor.test.tsx web/src/objects/options-combobox.test.tsx web/src/shell/breadcrumb.test.tsx
|
||||
git commit -m "fix(web): a11y labelling — useId, named drawer/breadcrumb, translated combobox (#62)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Task 2: objects-table — real-link rows, pill focus ring, announced load/error
|
||||
|
||||
**Files:** Modify `web/src/objects/objects-table.tsx`, `web/src/objects/objects-table.test.tsx`.
|
||||
|
||||
- [ ] **Step 1: Import `focusRing`.** Add `import { focusRing } from "../lib/focus-ring";` to `objects-table.tsx`. (`Link` is already imported from `react-router-dom`.)
|
||||
|
||||
- [ ] **Step 2: Filter pills — add the focus ring.** In the `toolbar`, change the pill `className` to:
|
||||
```tsx
|
||||
className={`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rows — real link + `aria-current`, plain `<tr>`.** Replace the data-row `<tr>` (the `role="link"` one) with:
|
||||
```tsx
|
||||
<tr
|
||||
key={object.id}
|
||||
onClick={() => 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">
|
||||
<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} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{object.current_location ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{object.number_of_objects}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{formatUpdated(object.updated_at)}</td>
|
||||
</tr>
|
||||
```
|
||||
(Drops `role="link"`, `tabIndex={0}`, `aria-selected`, and `onKeyDown` from the `<tr>`; the object-number cell now holds the `<Link>`. Every other cell is unchanged.)
|
||||
|
||||
- [ ] **Step 4: Error cell — `role="alert"`.** In the `isError` branch, change the error `<td>` to:
|
||||
```tsx
|
||||
<td colSpan={6} role="alert" className="px-3 py-6 text-center text-sm text-destructive">
|
||||
{t("objects.loadError")}
|
||||
</td>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Table — `aria-busy` + live caption.** Change the `<table>` element and add the caption as its first child:
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Tests — extend `objects-table.test.tsx`.** Add a nested-route helper (so `ObjectsTable` is mounted WITH a `:id` param, mirroring the real `ObjectsPage` nesting) and the new assertions. Add near the existing `tree()`:
|
||||
```tsx
|
||||
function nestedTree() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/objects"
|
||||
element={
|
||||
<>
|
||||
<ObjectsTable />
|
||||
<Outlet />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Route path=":id" element={<div>detail pane</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
```
|
||||
(Add `Outlet` to the `react-router-dom` import.) Then add these tests:
|
||||
```tsx
|
||||
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 () => {
|
||||
// objectsPage.items[0] has object_number "LM-0042"; read its id from the fixture.
|
||||
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");
|
||||
// a different row's link is not current
|
||||
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);
|
||||
});
|
||||
```
|
||||
(Add `delay` to the `msw` import: `import { delay, http, HttpResponse } from "msw";`. The existing "clicking a row deep-links…" test clicks "Amphora" — the name cell, still plain text + whole-row `onClick` — so it stays green. If `objectsPage.items[0]` doesn't carry an `id`, read `src/test/fixtures.ts` to use the correct id field.)
|
||||
|
||||
- [ ] **Step 7: FULL FRONTEND GATE (run tests EXACTLY ONCE):**
|
||||
```bash
|
||||
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
|
||||
```
|
||||
All green. Report test totals, largest chunk (gz), and the `check:colors` line.
|
||||
|
||||
- [ ] **Step 8: Codename + status:**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
|
||||
git status --short
|
||||
```
|
||||
Expected: no matches (`codename-exit=1`).
|
||||
|
||||
- [ ] **Step 9: Manual smoke (recommended).** `pnpm dev`: tab into the objects table — the visibility pills show a focus ring; Tab reaches each row's object-number link (Enter opens; Cmd/middle-click opens a new tab); the open object's row link is `aria-current`; a slow/failed load is announced.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web/src/objects/objects-table.tsx web/src/objects/objects-table.test.tsx
|
||||
git commit -m "fix(web): objects-table a11y — real-link rows, pill focus ring, announced load/error (#62)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage:** AC1 LabelEditor useId (T1 S2); AC2 row real-link + aria-current + plain tr + pill focusRing (T2 S2-S3); AC3 aria-busy + live caption + role=alert (T2 S4-S5); AC4 drawer + breadcrumb names + combobox translation (T1 S3-S5); AC5 gate/parity/codename (T2 S7-S8, T1 S1/S7). ✓
|
||||
|
||||
**Placeholder scan:** every code step shows full code; tests give concrete role/name assertions; the two "mirror the existing harness" notes (label-editor/options-combobox/breadcrumb tests) point at named existing files to copy from, not vague TODOs; the fixture-id note names the exact field to read. No TBD. ✓
|
||||
|
||||
**Type/consistency:** `focusRing` (string) imported once in T2 and used on pills + row link; `aria-current={selected ? "page" : undefined}` consistent; the 5 i18n keys added in T1 S1 are consumed in T1 S3-S5 (`common.clear/open`, `nav.breadcrumb`, `objects.detailTitle`) and T2 S5 (`objects.tableLabel`). ✓
|
||||
|
||||
## Notes
|
||||
- No new dependency. `components/ui/*` untouched (combobox/drawer wrappers unchanged; only props passed from callers). `check:colors` stays green — `focusRing` uses `ring-ring` tokens, no raw palette.
|
||||
- The combobox wrapper's own raw-palette internals and the segmented-control extraction are #66, not here.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Accessibility Defect Bundle — Design
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||
**Issue:** #62 (label-id collision, invalid table row semantics, unnamed drawer, unannounced table states, last untranslated strings).
|
||||
|
||||
## Context
|
||||
|
||||
A frontend deep audit (post the #52 a11y pass, which is verified correct) found five remaining
|
||||
accessibility gaps. They are independent, low-risk fixes; no new dependency. The #52 work (focus ring,
|
||||
skip link, route focus, authority nav links, lang group, `<html lang>` sync) stays untouched.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `components/label-editor.tsx` — id collision → `useId()`
|
||||
`LabelEditor` hardcodes `<Label htmlFor="label">` + `<Input id="label">` (`:35-36`). It renders
|
||||
simultaneously in the create form **and** any inline-edit row (authorities + vocab), so two `id="label"`
|
||||
inputs coexist and every `<label for="label">` resolves to the first — the second editor's label points
|
||||
at the wrong field (WCAG 1.3.1 / 4.1.1 / 4.1.2). Fix with React's `useId()` (zero call-site changes):
|
||||
```tsx
|
||||
import { useId } from "react";
|
||||
// …
|
||||
const inputId = useId();
|
||||
<Label htmlFor={inputId}>{t("labels.label")}</Label>
|
||||
<Input id={inputId} value={current} onChange={(e) => set(e.target.value)} />
|
||||
```
|
||||
|
||||
### 2. `objects/objects-table.tsx` — rows: real link + `aria-current`
|
||||
The data rows are `<tr role="link" tabIndex={0} aria-selected={selected} onClick onKeyDown>` (`:247-259`).
|
||||
`aria-selected` is invalid on `role="link"` (AT ignores it). Decision (brainstorming): make the
|
||||
object-**number** cell a real React Router `<Link>` and keep the whole row clickable. The `<tr>` becomes a
|
||||
plain row:
|
||||
```tsx
|
||||
<tr
|
||||
key={object.id}
|
||||
onClick={() => 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">
|
||||
<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>
|
||||
{/* …other cells unchanged… */}
|
||||
</tr>
|
||||
```
|
||||
- Native anchor ⇒ keyboard focus + Enter activation + SR "link" + middle-click / open-in-new-tab, all free.
|
||||
- `event.stopPropagation()` on the link prevents the row `onClick` from double-navigating when the link
|
||||
itself is clicked.
|
||||
- The whole-row `onClick` stays, so clicking any non-link cell still opens the object (preserves current
|
||||
UX; the existing "clicking a row …" test clicks the object-**name** cell and still passes).
|
||||
- Selection is conveyed by `aria-current="page"` on the link (announced when focused); the visual
|
||||
highlight stays on the `<tr>` via the className.
|
||||
- Drop `role="link"`, `tabIndex`, `onKeyDown`, and `aria-selected` from the `<tr>`.
|
||||
|
||||
### 2b. `objects/objects-table.tsx` — filter pills: restore `focusRing`
|
||||
The visibility pills (`:168-177`) lack the keyboard focus ring their siblings (`search-panel`,
|
||||
`authorities-page`) have. Import `focusRing` from `../lib/focus-ring` and append it to the pill className:
|
||||
```tsx
|
||||
className={`${focusRing} rounded-md px-2 py-1 ${active ? "bg-primary text-primary-foreground" : "border"}`}
|
||||
```
|
||||
|
||||
### 3. `objects/objects-table.tsx` — announce loading / error
|
||||
Loading renders bare `<Skeleton>` rows and the error a plain `<td>` — neither is announced (WCAG 4.1.3).
|
||||
- Add `aria-busy={isLoading || undefined}` to the `<table>`.
|
||||
- Add an `sr-only` live `<caption>` that announces loading and settles to the table name (also gives the
|
||||
table an accessible name):
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
- Add `role="alert"` to the error `<td>` (`:223`) so a load failure is announced assertively:
|
||||
```tsx
|
||||
<td colSpan={6} role="alert" className="px-3 py-6 text-center text-sm text-destructive">
|
||||
{t("objects.loadError")}
|
||||
</td>
|
||||
```
|
||||
|
||||
### 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
|
||||
<DrawerContent aria-label={t("objects.detailTitle")}>
|
||||
```
|
||||
(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 `<ComboboxEmpty>{t("common.noMatches")}</ComboboxEmpty>`
|
||||
(`common.noMatches` already exists).
|
||||
- `shell/breadcrumb.tsx` (`:10`): add `useTranslation`; `<nav aria-label={t("nav.breadcrumb")}>`.
|
||||
|
||||
### 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 `<Link>` respects them).
|
||||
- `aria-current` is omitted (not `"false"`) when the row isn't selected.
|
||||
- The `<caption>` 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 `<label>` 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: <object_number> })`); (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 `<nav>` 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 `<Link>` (object-number cell) with `aria-current="page"` on the
|
||||
selected row; no `role="link"`/`aria-selected` on the `<tr>`; 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 `<nav>` 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.
|
||||
@@ -85,3 +85,16 @@ test("no hint when only the default-language label exists", () => {
|
||||
renderApp(<Harness initial={[{ lang: "sv", label: "Brons" }]} />);
|
||||
expect(screen.queryByText(/other languages/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("each LabelEditor instance gets a unique input id", () => {
|
||||
renderApp(
|
||||
<>
|
||||
<LabelEditor value={[]} onChange={() => {}} />
|
||||
<LabelEditor value={[]} onChange={() => {}} />
|
||||
</>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="label">{t("labels.label")}</Label>
|
||||
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
|
||||
<Label htmlFor={inputId}>{t("labels.label")}</Label>
|
||||
<Input id={inputId} value={current} onChange={(e) => set(e.target.value)} />
|
||||
{hasOtherLanguages && (
|
||||
<p className="text-xs text-muted-foreground">{t("labels.otherLanguages")}</p>
|
||||
)}
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ObjectDetailDrawer({
|
||||
}}
|
||||
swipeDirection="right"
|
||||
>
|
||||
<DrawerContent>
|
||||
<DrawerContent aria-label={t("objects.detailTitle")}>
|
||||
<div className="flex justify-end border-b p-2">
|
||||
<DrawerClose
|
||||
aria-label={t("actions.closeDetail")}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<ComboboxInputGroup>
|
||||
<ComboboxInput id={id} placeholder={placeholder} />
|
||||
<ComboboxClear aria-label="Clear" />
|
||||
<ComboboxTrigger aria-label="Open" />
|
||||
<ComboboxClear aria-label={t("common.clear")} />
|
||||
<ComboboxTrigger aria-label={t("common.open")} />
|
||||
</ComboboxInputGroup>
|
||||
|
||||
<ComboboxPopup>
|
||||
<ComboboxEmpty>No matches.</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("common.noMatches")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: Option) => (
|
||||
<ComboboxItem key={option.id} value={option}>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 <div className="min-w-0 flex-1" />;
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="flex min-w-0 flex-1 items-center gap-1 text-sm">
|
||||
<nav aria-label={t("nav.breadcrumb")} className="flex min-w-0 flex-1 items-center gap-1 text-sm">
|
||||
{trail.map((item, i) => {
|
||||
const last = i === trail.length - 1;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user