Web: Kategorie-Elternwahl als aufklappbarer Baum statt "— —"-Select

Die uebergeordnete Kategorie wird nicht mehr ueber ein <select> mit
"— —"-Einrueckung gewaehlt, sondern ueber einen aufklappbaren Baum-Picker
(CategorySelect): echte Einrueckung, Aeste per Pfeil auf-/zuklappbar wie in der
Liste. Das Menue haengt per Portal am <body>, damit der horizontal scrollende
Tabellenrahmen es nicht abschneidet. Ersetzt beide Stellen (Zeilen-Elternwahl
und Anlege-Formular); die kompakten Knoepfe machen die Zeile schmaler, sodass
die Tabelle nicht mehr seitwaerts ueber den Block hinauslaeuft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 11:44:03 +02:00
parent 914118e33a
commit f35126cb7b
3 changed files with 200 additions and 18 deletions

View File

@@ -0,0 +1,151 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
/**
* Aufklappbarer Kategorie-Baum als Auswahlfeld Ersatz für die früheren, mit
* „— —“ eingerückten <select>. Zeigt echte Einrückung und lässt Äste per Pfeil
* ein- und ausklappen (wie die Kategorienliste selbst). Gibt `null` für die
* oberste Ebene zurück, sonst die Kategorie-ID.
*
* Das Menü wird per Portal an den <body> gehängt, damit es nicht vom
* horizontal scrollenden Tabellenrahmen (overflow) abgeschnitten wird.
*/
export default function CategorySelect({
value,
onChange,
nodes,
rootLabel = " oberste Ebene ",
disabled = false,
}) {
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(() => new Set());
const [pos, setPos] = useState(null);
const btnRef = useRef(null);
const popRef = useRef(null);
const nameById = Object.fromEntries(nodes.map((n) => [n.id, n.name]));
const parentById = Object.fromEntries(nodes.map((n) => [n.id, n.parent_id]));
const idset = new Set(nodes.map((n) => n.id));
const hasKids = (id) => nodes.some((n) => n.parent_id === id);
const selected = value == null || value === "" ? null : Number(value);
const label = selected == null ? rootLabel : nameById[selected] ?? rootLabel;
function place() {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.left, top: r.bottom + 4, width: r.width });
}
useLayoutEffect(() => { if (open) place(); }, [open]);
useEffect(() => {
if (!open) return;
function onDoc(e) {
if (btnRef.current?.contains(e.target)) return;
if (popRef.current?.contains(e.target)) return;
setOpen(false);
}
function onAway() { setOpen(false); }
function onKey(e) { if (e.key === "Escape") setOpen(false); }
document.addEventListener("mousedown", onDoc);
// Beim Scrollen/Größenwechsel würde das fixierte Menü verrutschen lieber
// schließen.
window.addEventListener("scroll", onAway, true);
window.addEventListener("resize", onAway);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
window.removeEventListener("scroll", onAway, true);
window.removeEventListener("resize", onAway);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function hidden(id) {
let p = parentById[id];
while (p != null && idset.has(p)) {
if (collapsed.has(p)) return true;
p = parentById[p];
}
return false;
}
function toggle(id) {
setCollapsed((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
return neu;
});
}
function pick(id) {
onChange(id);
setOpen(false);
}
const visible = nodes.filter((n) => !hidden(n.id));
return (
<>
<button
type="button"
ref={btnRef}
className="tree-select-btn"
disabled={disabled}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className={selected == null ? "muted" : ""}>{label}</span>
<Icon name="chevronRight" size={13} className={`caret ${open ? "open" : ""}`} />
</button>
{open && pos && createPortal(
<div
ref={popRef}
className="tree-pop"
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 220) }}
>
<button
type="button"
className={`tree-pop-opt ${selected == null ? "sel" : ""}`}
style={{ paddingLeft: 12 }}
onClick={() => pick(null)}
>
{rootLabel}
</button>
{visible.map((n) => {
const kids = hasKids(n.id);
const zu = collapsed.has(n.id);
return (
<div key={n.id} className="tree-pop-line" style={{ paddingLeft: 6 + n.depth * 16 }}>
{kids ? (
<span
className="tree-toggle"
role="button"
tabIndex={0}
title={zu ? "Unterkategorien einblenden" : "Unterkategorien ausblenden"}
onClick={(e) => { e.stopPropagation(); toggle(n.id); }}
>
<Icon name="chevronRight" size={12} className={zu ? "" : "rotated"} />
</span>
) : (
<span className="tree-spacer-sm" />
)}
<button
type="button"
className={`tree-pop-opt ${selected === n.id ? "sel" : ""}`}
onClick={() => pick(n.id)}
>
{n.name}
</button>
</div>
);
})}
{nodes.length === 0 && <div className="tree-pop-empty">Keine Auswahl möglich.</div>}
</div>,
document.body,
)}
</>
);
}