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>
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|