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