Files
nexarch/web/dms-browser/components/DocumentList.tsx
T
patrickandClaude Sonnet 5 e973457d1a DOC-10: Ordner-Navigation & Dokumentenliste
internal/folderapi: neue Backend-Voraussetzung - GET /folders/{id}/
children (lazy Baum-Ladung), .../breadcrumb (ueber DOC-09s Materialized
Path), .../documents (Dokumentenliste, whitelisted Sortierspalten).
folder.go ergaenzt ListChildren/Breadcrumb (additiv, keine bestehende
Funktion geaendert).

web/dms-browser: Next.js/React/TypeScript. Navigationsbaum laedt Kinder
lazy je Ebene, Dokumentenliste ist virtualisiert (lib/virtualization.ts,
abhaengigkeitsfrei - nur sichtbarer Ausschnitt + Overscan wird gerendert),
Mehrfachauswahl (lib/selection.ts: Klick/Strg/Umschalt), Tastatur-
navigation im Baum (lib/treeKeyboard.ts). Aktueller Ordner steckt in der
URL (Catch-all-Route /browse/[[...path]]) statt nur im React-State - der
Browser verwaltet Vor-/Zurueck-Navigation selbst.

Echter Bug beim Schreiben der Virtualisierungs-Tests gefunden und
behoben: startIndex war bei scrollTop jenseits des tatsaechlichen Inhalts
nicht auf totalRows geklemmt (haette zu ungueltigem slice() gefuehrt).

Pruefungen:
1. Liste mit 10.000 Zeilen scrollt ruckelfrei - Virtualisierung rendert
   nachweislich nur ~20 statt 10.000 Zeilen
2. Navigation vollstaendig per Tastatur - 5 Vitest-Tests fuer Pfeiltasten/
   Enter im Baum
3. Zustand bleibt beim Zurueck-Navigieren erhalten - Ordner-ID in der URL,
   Browser-History uebernimmt die Wiederherstellung

Regressionsfrei: alle bestehenden Go-Pakete PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:22:09 +02:00

101 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// DOC-10: virtualisierte Dokumentenliste — rendert bei sehr vielen
// Einträgen nur den sichtbaren Ausschnitt (siehe lib/virtualization.ts),
// damit die Liste bei 10.000 Zeilen ruckelfrei scrollt (Akzeptanzkriterium
// 2 / Pflichtprüfung 1).
import { useMemo, useRef, useState } from "react";
import type { DocumentDTO } from "../lib/api";
import { computeVisibleRange, totalContentHeight } from "../lib/virtualization";
import { ClickModifiers, SelectionState, applyClick, isSelected } from "../lib/selection";
const ROW_HEIGHT = 32;
const CONTAINER_HEIGHT = 480;
export interface DocumentListProps {
documents: DocumentDTO[];
selection: SelectionState;
onSelectionChange: (next: SelectionState) => void;
sortColumn: "title" | "created_at" | "size";
sortOrder: "asc" | "desc";
onSortChange: (column: "title" | "created_at" | "size") => void;
}
export function DocumentList({
documents,
selection,
onSelectionChange,
sortColumn,
sortOrder,
onSortChange,
}: DocumentListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const ids = useMemo(() => documents.map((d) => d.id), [documents]);
const range = computeVisibleRange(scrollTop, CONTAINER_HEIGHT, ROW_HEIGHT, documents.length);
const visibleDocs = documents.slice(range.startIndex, range.endIndex);
function onRowClick(index: number, e: React.MouseEvent) {
const modifiers: ClickModifiers = { shiftKey: e.shiftKey, ctrlOrMetaKey: e.ctrlKey || e.metaKey };
onSelectionChange(applyClick(selection, ids, index, modifiers));
}
function sortHeader(column: "title" | "created_at" | "size", label: string) {
const indicator = sortColumn === column ? (sortOrder === "asc" ? " ▲" : " ▼") : "";
return (
<button type="button" onClick={() => onSortChange(column)} style={{ fontWeight: 600 }}>
{label}
{indicator}
</button>
);
}
return (
<div>
<div role="row" style={{ display: "flex", gap: 16, borderBottom: "1px solid #ccc", paddingBottom: 4 }}>
<div style={{ flex: 2 }}>{sortHeader("title", "Titel")}</div>
<div style={{ flex: 1 }}>{sortHeader("size", "Größe")}</div>
<div style={{ flex: 1 }}>{sortHeader("created_at", "Erstellt am")}</div>
</div>
<div
ref={containerRef}
role="listbox"
aria-label="Dokumentenliste"
aria-multiselectable="true"
style={{ height: CONTAINER_HEIGHT, overflowY: "auto", position: "relative" }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalContentHeight(documents.length, ROW_HEIGHT), position: "relative" }}>
<div style={{ position: "absolute", top: range.offsetY, left: 0, right: 0 }}>
{visibleDocs.map((doc, i) => {
const index = range.startIndex + i;
const selected = isSelected(selection, doc.id);
return (
<div
key={doc.id}
role="option"
aria-selected={selected}
onClick={(e) => onRowClick(index, e)}
style={{
display: "flex",
gap: 16,
height: ROW_HEIGHT,
alignItems: "center",
background: selected ? "var(--shl-color-accent-subtle, #dbe9ff)" : "transparent",
cursor: "pointer",
}}
>
<div style={{ flex: 2 }}>{doc.title}</div>
<div style={{ flex: 1 }}>{doc.size_bytes != null ? `${doc.size_bytes} B` : "–"}</div>
<div style={{ flex: 1 }}>{new Date(doc.created_at).toLocaleDateString("de-DE")}</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}