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
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 13:24:07 +02:00
1 parent aef2003d4c
commit 8f442c3d6c
9 files changed
+173 -8

No files matched your search

+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -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(
<I18nProvider initialLocale="en">
<DocumentTimeline
documents={[]}
uploadItems={uploadItems}
onDismissUpload={() => {}}
hasNextPage={false}
isFetchingNextPage={false}
onLoadMore={() => {}}
selectedIds={new Set()}
onToggleSelected={() => {}}
onOpen={() => {}}
highlightId={null}
/>
</I18nProvider>,
);
}
describe("<DocumentTimeline> 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");
});
});
+106 -2
View File
@@ -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<string>;
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<string | null>(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<string, number>();
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<HTMLButtonElement>(
`[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<HTMLDivElement>) => {
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({
</div>
) : null}
<div ref={listRef}>
{/* 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. */}
<div
ref={listRef}
role="list"
aria-label={t("documents.timeline.listLabel")}
onKeyDown={onGridKeyDown}
>
<div
className="relative"
style={{ height: virtualizer.getTotalSize() }}
@@ -169,6 +269,7 @@ export function DocumentTimeline({
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
role="listitem"
className="absolute inset-x-0 top-0"
style={{
transform: `translateY(${
@@ -194,7 +295,10 @@ export function DocumentTimeline({
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={setActiveId}
onPrefetch={onPrefetch}
/>
))}
@@ -577,6 +577,7 @@ export function DocumentsView() {
selectedIds={selectedIds}
onToggleSelected={toggleSelected}
onOpen={openDetail}
onDelete={(id) => deleteBulk([id])}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>