Files
nexarch/web/dms-browser/components/FolderTree.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

125 lines
3.8 KiB
TypeScript

"use client";
// DOC-10: Navigationsbaum — lädt Kinder lazy je Ebene (kein kompletter
// Teilbaum auf einmal), per Tastatur bedienbar (Akzeptanzkriterium/
// Pflichtprüfung 2: Pfeiltasten, Enter/Leertaste).
import { useCallback, useEffect, useRef, useState } from "react";
import type { FolderDTO } from "../lib/api";
import { fetchChildren } from "../lib/api";
import { mapTreeKeyToAction, moveFocus } from "../lib/treeKeyboard";
interface TreeNode {
folder: FolderDTO;
depth: number;
expanded: boolean;
children: TreeNode[] | null; // null = noch nicht geladen
}
export interface FolderTreeProps {
selectedFolderID: string | null;
onSelect: (folderID: string) => void;
}
export function FolderTree({ selectedFolderID, onSelect }: FolderTreeProps) {
const [roots, setRoots] = useState<TreeNode[] | null>(null);
const [focusedIndex, setFocusedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchChildren(null).then((children) => {
setRoots(children.map((f) => ({ folder: f, depth: 0, expanded: false, children: null })));
});
}, []);
// Flache, sichtbare Reihenfolge (nur eingeklappte Zweige werden
// aufgeführt) — Grundlage für die Tastaturnavigation, dieselbe Idee wie
// lib/treeKeyboard.ts erwartet.
function flatten(nodes: TreeNode[]): TreeNode[] {
const out: TreeNode[] = [];
for (const n of nodes) {
out.push(n);
if (n.expanded && n.children) {
out.push(...flatten(n.children));
}
}
return out;
}
const visible = roots ? flatten(roots) : [];
const toggleExpand = useCallback(
async (node: TreeNode) => {
if (!node.expanded && node.children === null) {
const children = await fetchChildren(node.folder.id);
node.children = children.map((f) => ({ folder: f, depth: node.depth + 1, expanded: false, children: null }));
}
node.expanded = !node.expanded;
setRoots((r) => (r ? [...r] : r));
},
[]
);
function onKeyDown(e: React.KeyboardEvent) {
const action = mapTreeKeyToAction(e.key);
if (!action) return;
e.preventDefault();
const node = visible[focusedIndex];
switch (action.type) {
case "moveDown":
setFocusedIndex((i) => moveFocus(i, visible.length, 1));
break;
case "moveUp":
setFocusedIndex((i) => moveFocus(i, visible.length, -1));
break;
case "expand":
if (node && !node.expanded) void toggleExpand(node);
else setFocusedIndex((i) => moveFocus(i, visible.length, 1));
break;
case "collapse":
if (node?.expanded) void toggleExpand(node);
break;
case "select":
if (node) onSelect(node.folder.id);
break;
}
}
if (!roots) {
return <p>Lade Ordnerbaum…</p>;
}
return (
<div
ref={containerRef}
role="tree"
aria-label="Ordnerbaum"
tabIndex={0}
onKeyDown={onKeyDown}
style={{ outline: "none" }}
>
{visible.map((node, index) => (
<div
key={node.folder.id}
role="treeitem"
aria-selected={node.folder.id === selectedFolderID}
aria-expanded={node.children !== null ? node.expanded : undefined}
tabIndex={-1}
style={{
paddingLeft: node.depth * 16,
background: index === focusedIndex ? "var(--shl-color-accent-subtle, #eef3ff)" : "transparent",
fontWeight: node.folder.id === selectedFolderID ? 600 : 400,
cursor: "pointer",
}}
onClick={() => {
setFocusedIndex(index);
onSelect(node.folder.id);
}}
onDoubleClick={() => void toggleExpand(node)}
>
{node.expanded ? "▾" : "▸"} {node.folder.name}
</div>
))}
</div>
);
}