From 8f442c3d6ceda71e7a203cc22ad154349ab0f62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 13:24:07 +0200 Subject: [PATCH] feat(documents): keyboard-first vault timeline Roving tabindex over the card grid: arrows move (grid-aware by measured column count), Home/End jump, Enter opens, Space selects, Delete removes with the undo toast; focus rides the virtualizer window (scroll-to-row, bounded retry focus). The container carries list semantics with a translated label, and the transient upload queue stays a polite live region. Selection state is conveyed by the per-card checkbox, which the focus ring reveals for keyboard users. Refs: document-vault plan W3-T3 --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../__tests__/document-timeline.test.tsx | 54 +++++++++ .../documents/document-timeline.tsx | 108 +++++++++++++++++- src/components/documents/documents-view.tsx | 1 + 9 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 src/components/documents/__tests__/document-timeline.test.tsx diff --git a/messages/de.json b/messages/de.json index 5bb57afc6..3b8b1e11f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8060,7 +8060,8 @@ "restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden." }, "timeline": { - "loadingMore": "Mehr wird geladen…" + "loadingMore": "Mehr wird geladen…", + "listLabel": "Dokumenten-Zeitleiste" }, "upload": { "zoneTitle": "Dateien hier ablegen oder vom Gerät auswählen", diff --git a/messages/en.json b/messages/en.json index 673cb767a..12b8182d1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8060,7 +8060,8 @@ "restoreFailed": "Couldn't restore the document." }, "timeline": { - "loadingMore": "Loading more…" + "loadingMore": "Loading more…", + "listLabel": "Documents timeline" }, "upload": { "zoneTitle": "Drop files here, or choose from your device", diff --git a/messages/es.json b/messages/es.json index 0d5065b20..e8074a138 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8060,7 +8060,8 @@ "restoreFailed": "No se pudo restaurar el documento." }, "timeline": { - "loadingMore": "Cargando más…" + "loadingMore": "Cargando más…", + "listLabel": "Cronología de documentos" }, "upload": { "zoneTitle": "Suelta archivos aquí o elige desde tu dispositivo", diff --git a/messages/fr.json b/messages/fr.json index 7455b5a61..30fb93689 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8060,7 +8060,8 @@ "restoreFailed": "Impossible de restaurer le document." }, "timeline": { - "loadingMore": "Chargement…" + "loadingMore": "Chargement…", + "listLabel": "Chronologie des documents" }, "upload": { "zoneTitle": "Déposez des fichiers ici ou choisissez depuis votre appareil", diff --git a/messages/it.json b/messages/it.json index 1a8a6582e..87e4e4b81 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8060,7 +8060,8 @@ "restoreFailed": "Impossibile ripristinare il documento." }, "timeline": { - "loadingMore": "Caricamento…" + "loadingMore": "Caricamento…", + "listLabel": "Cronologia dei documenti" }, "upload": { "zoneTitle": "Trascina i file qui o scegli dal dispositivo", diff --git a/messages/pl.json b/messages/pl.json index adf178ba2..50c6ba0f7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8060,7 +8060,8 @@ "restoreFailed": "Nie udało się przywrócić dokumentu." }, "timeline": { - "loadingMore": "Wczytywanie…" + "loadingMore": "Wczytywanie…", + "listLabel": "Oś czasu dokumentów" }, "upload": { "zoneTitle": "Upuść pliki tutaj lub wybierz z urządzenia", diff --git a/src/components/documents/__tests__/document-timeline.test.tsx b/src/components/documents/__tests__/document-timeline.test.tsx new file mode 100644 index 000000000..3c080f3e6 --- /dev/null +++ b/src/components/documents/__tests__/document-timeline.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * Static-render pins for the timeline's accessibility contract: the + * virtualized container is a labelled list, and the transient upload queue + * is a polite live region. (The windowed rows themselves only mount + * client-side once the virtualizer measures the scroll container — the + * keyboard path is covered by the e2e battery.) + */ +import { I18nProvider } from "@/lib/i18n/context"; +import { DocumentTimeline } from "../document-timeline"; +import type { UploadQueueItem } from "../use-document-upload"; + +function render(uploadItems: UploadQueueItem[]) { + return renderToStaticMarkup( + + {}} + hasNextPage={false} + isFetchingNextPage={false} + onLoadMore={() => {}} + selectedIds={new Set()} + onToggleSelected={() => {}} + onOpen={() => {}} + highlightId={null} + /> + , + ); +} + +describe(" semantics", () => { + it("renders the virtualized container as a labelled list", () => { + const html = render([]); + expect(html).toContain('role="list"'); + expect(html).toContain('aria-label="Documents timeline"'); + }); + + it("announces the upload queue through a polite live region", () => { + const html = render([ + { + localId: "l1", + fileName: "scan.pdf", + byteSize: 1024, + status: "uploading", + progress: 0.4, + }, + ]); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain("scan.pdf"); + }); +}); diff --git a/src/components/documents/document-timeline.tsx b/src/components/documents/document-timeline.tsx index 4c47c1886..77c863832 100644 --- a/src/components/documents/document-timeline.tsx +++ b/src/components/documents/document-timeline.tsx @@ -17,7 +17,13 @@ * < 100 ms after file selection, before any query settles. */ import { Loader2 } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTranslations } from "@/lib/i18n/context"; @@ -46,6 +52,7 @@ export function DocumentTimeline({ selectedIds, onToggleSelected, onOpen, + onDelete, highlightId, onPrefetch, }: { @@ -58,6 +65,8 @@ export function DocumentTimeline({ selectedIds: ReadonlySet; onToggleSelected: (id: string, range?: boolean) => void; onOpen: (id: string) => void; + /** Delete key on the focused card — the page owns the undo-able delete. */ + onDelete?: (id: string) => void; highlightId: string | null; onPrefetch?: (id: string) => void; }) { @@ -66,6 +75,11 @@ export function DocumentTimeline({ const [columns, setColumns] = useState(1); const [scrollMargin, setScrollMargin] = useState(0); + // Roving tabindex over the card grid: exactly one card is tabbable; the + // arrow keys move the active slot. Falls back to the first document when + // the remembered card left the corpus (filter change, deletion). + const [activeId, setActiveId] = useState(null); + // Measured columns — the ResizeObserver drives the row chunking. useEffect(() => { const el = listRef.current; @@ -117,6 +131,81 @@ export function DocumentTimeline({ const virtualItems = virtualizer.getVirtualItems(); + // ── Keyboard navigation (roving tabindex) ───────────────────────────── + // The tabbable slot: the remembered active card, else the first document. + const rovingId = + activeId !== null && documents.some((d) => d.id === activeId) + ? activeId + : (documents[0]?.id ?? null); + + // Which virtual item (grid row) a document renders in — the arrow-key + // handler scrolls that row into the window before focusing the card. + const rowIndexById = useMemo(() => { + const map = new Map(); + items.forEach((item, index) => { + if (item.type !== "row") return; + for (const doc of item.documents) map.set(doc.id, index); + }); + return map; + }, [items]); + + const focusDocument = (id: string) => { + setActiveId(id); + const rowIndex = rowIndexById.get(id); + if (rowIndex !== undefined) { + virtualizer.scrollToIndex(rowIndex, { align: "auto" }); + } + // The row may only mount on the next virtualizer paint — retry across + // a few frames, bounded. + let attempts = 0; + const tryFocus = () => { + const button = listRef.current?.querySelector( + `[data-document-id="${CSS.escape(id)}"] [data-slot="document-open"]`, + ); + if (button) { + button.focus(); + return; + } + attempts += 1; + if (attempts < 20) requestAnimationFrame(tryFocus); + }; + requestAnimationFrame(tryFocus); + }; + + const onGridKeyDown = (event: ReactKeyboardEvent) => { + if (documents.length === 0) return; + const currentIndex = rovingId + ? documents.findIndex((d) => d.id === rovingId) + : 0; + let nextIndex: number | null = null; + switch (event.key) { + case "ArrowRight": + nextIndex = Math.min(documents.length - 1, currentIndex + 1); + break; + case "ArrowLeft": + nextIndex = Math.max(0, currentIndex - 1); + break; + case "ArrowDown": + nextIndex = Math.min(documents.length - 1, currentIndex + columns); + break; + case "ArrowUp": + nextIndex = Math.max(0, currentIndex - columns); + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = documents.length - 1; + break; + default: + return; + } + event.preventDefault(); + if (nextIndex !== currentIndex) { + focusDocument(documents[nextIndex].id); + } + }; + // Keyset infinite feed: pull the next page when the window nears the end. const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? -1; useEffect(() => { @@ -157,7 +246,18 @@ export function DocumentTimeline({ ) : null} -
+ {/* List semantics over the virtualized window: the container is one + list, each windowed item (month label or card row) one list item — + honest for a windowed structure where per-document posinset would + lie whenever pages are still loading. Keyboard contract on the + grid: arrows move the roving slot, Enter opens, Space selects, + Delete removes (undo-able), documented on the cards. */} +
))} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index df779d810..e5a3ef44c 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -577,6 +577,7 @@ export function DocumentsView() { selectedIds={selectedIds} onToggleSelected={toggleSelected} onOpen={openDetail} + onDelete={(id) => deleteBulk([id])} highlightId={upload.highlightId} onPrefetch={prefetchDetail} />