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:
151
web/src/components/CategorySelect.jsx
Normal file
151
web/src/components/CategorySelect.jsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user