mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
aef2003d4c
commit
8f442c3d6c
9 files changed
+173
-8
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user