"use client"; /** * The vault timeline: a virtualized, month-sectioned card grid windowed * with `@tanstack/react-virtual` over the shell's scroll container * (`#main-content` — single scroll owner per the design standards; the * timeline never brings its own scrollport). The flat item list (month * labels + chunked card rows) comes from `buildTimelineItems`, so the * mounted DOM stays bounded (< ~400 nodes) regardless of corpus size. * * Columns are measured, not breakpoint-classed: a ResizeObserver on the * grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps * the virtualizer's row model and the painted grid in lockstep. * * In-flight / failed uploads render as a small non-virtualized grid above * the timeline — they are few (client concurrency 3) and must appear * < 100 ms after file selection, before any query settles. */ import { Loader2 } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTranslations } from "@/lib/i18n/context"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DocumentCard, UploadStateCard } from "./document-card"; import type { UploadQueueItem } from "./use-document-upload"; import { buildTimelineItems, formatMonthLabel } from "./vault-utils"; const SCROLL_CONTAINER_ID = "main-content"; /** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */ function columnsForWidth(width: number): number { if (width >= 1200) return 4; if (width >= 900) return 3; if (width >= 600) return 2; return 1; } export function DocumentTimeline({ documents, uploadItems, onDismissUpload, hasNextPage, isFetchingNextPage, onLoadMore, selectedIds, onToggleSelected, onOpen, onDelete, highlightId, onPrefetch, }: { documents: InboundDocumentDto[]; uploadItems: UploadQueueItem[]; onDismissUpload: (localId: string) => void; hasNextPage: boolean; isFetchingNextPage: boolean; onLoadMore: () => void; selectedIds: ReadonlySet; /** Omitted inside somebody else's record: selection feeds bulk edits. */ 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; }) { const { t, locale } = useTranslations(); const listRef = useRef(null); 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; if (!el) return; const update = () => setColumns(columnsForWidth(el.clientWidth)); update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); const items = useMemo( () => buildTimelineItems(documents, columns), [documents, columns], ); // The timeline does not start at the scrollport's top edge (page header, // filter bar, upload row sit above it) — feed the offset to the // virtualizer so window positions line up. Re-measured when the content // above changes height (upload cards appearing/leaving) and on resize. useEffect(() => { const scrollEl = document.getElementById(SCROLL_CONTAINER_ID); const listEl = listRef.current; if (!scrollEl || !listEl) return; const measure = () => { const margin = listEl.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop; setScrollMargin(margin); }; measure(); window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }, [uploadItems.length, columns]); // The React Compiler cannot memoize across TanStack Virtual's instance // API (library-level opt-out, not a fixable call-site problem) — the // windowing still works, the compiler just skips this component. // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID), estimateSize: (index) => (items[index].type === "month" ? 40 : 140), getItemKey: (index) => items[index].key, overscan: 6, scrollMargin, }); 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(() => { if ( hasNextPage && !isFetchingNextPage && lastIndex >= items.length - columns * 3 - 1 ) { onLoadMore(); } }, [ lastIndex, items.length, columns, hasNextPage, isFetchingNextPage, onLoadMore, ]); return (
{uploadItems.length > 0 ? (
{uploadItems.map((item) => ( ))}
) : 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. */}
{virtualItems.map((virtualItem) => { const item = items[virtualItem.index]; return (
{item.type === "month" ? (

{formatMonthLabel(item.key, locale)}

) : (
{item.documents.map((doc) => ( ))}
)}
); })}
{isFetchingNextPage ? (
{t("documents.timeline.loadingMore")}
) : null}
); }