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,
)}
</>
);
}

View File

@@ -5,6 +5,7 @@ import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { asTree } from "../categoryTree";
import { FIELD_TYPES, fieldTypeLabel } from "../fields";
import CategorySelect from "../components/CategorySelect";
const MODUS = { food: "Lebensmittel", object: "Gegenstand" };
@@ -199,15 +200,11 @@ export default function Categories() {
</td>
<td data-label="Übergeordnet">
{isAdmin ? (
<select value={c.parent_id ?? ""} style={{ marginTop: 0, minWidth: 150 }}
onChange={(e) => patch(c, {
parent_id: e.target.value === "" ? null : Number(e.target.value),
})}>
<option value=""> oberste Ebene </option>
{parentOptions.map((o) => (
<option key={o.id} value={o.id}>{"— ".repeat(o.depth)}{o.name}</option>
))}
</select>
<CategorySelect
value={c.parent_id ?? null}
nodes={parentOptions}
onChange={(pid) => patch(c, { parent_id: pid })}
/>
) : (
<span className="muted">{c.parent_id ? nameById[c.parent_id] : ""}</span>
)}
@@ -246,15 +243,11 @@ export default function Categories() {
</label>
<label>
Untergeordnet (optional)
<select value={form.parent_id}
onChange={(e) => setForm({ ...form, parent_id: e.target.value })}>
<option value=""> oberste Ebene </option>
{tree.map((c) => (
<option key={c.id} value={c.id}>
{"— ".repeat(c.depth)}{c.name}
</option>
))}
</select>
<CategorySelect
value={form.parent_id === "" ? null : Number(form.parent_id)}
nodes={tree}
onChange={(pid) => setForm({ ...form, parent_id: pid == null ? "" : String(pid) })}
/>
</label>
<label>
Verwaltungsart

View File

@@ -320,6 +320,44 @@ td select { width: auto; min-width: 0; max-width: 100%; }
.tree-toggle .icon.rotated { transform: rotate(90deg); }
/* Platzhalter, damit Zeilen ohne Unterkategorien buendig zu denen mit stehen. */
.tree-spacer { display: inline-block; flex: 0 0 auto; width: 24px; }
/* Aufklappbarer Kategorie-Baum als Auswahlfeld (CategorySelect). Der Knopf sieht
aus wie ein <select>, das Menue haengt per Portal am <body>. */
.tree-select-btn {
width: 100%; margin-top: 5px;
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
background: var(--surface); color: var(--text);
font-size: 0.9rem; font-family: inherit; text-align: left; cursor: pointer;
transition: border-color .12s, box-shadow .12s;
}
.tree-select-btn > span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-select-btn:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.tree-select-btn:disabled { opacity: 0.6; cursor: default; }
.tree-select-btn .caret { flex: 0 0 auto; color: var(--muted); transform: rotate(90deg); transition: transform .12s ease; }
.tree-select-btn .caret.open { transform: rotate(-90deg); }
.tree-pop {
position: fixed; z-index: 1000;
max-height: min(60vh, 360px); overflow-y: auto;
padding: 5px;
background: var(--surface); border: 1px solid var(--border-strong);
border-radius: var(--radius); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.tree-pop-line { display: flex; align-items: center; gap: 4px; }
.tree-pop-opt {
flex: 1 1 auto; min-width: 0; width: 100%; margin: 0;
padding: 6px 8px; border: 0; border-radius: var(--radius-sm);
background: transparent; color: var(--text); font: inherit; font-size: 0.9rem;
text-align: left; cursor: pointer; white-space: nowrap;
}
.tree-pop-opt:hover { background: var(--surface-2); }
.tree-pop-opt.sel { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.tree-spacer-sm { display: inline-block; flex: 0 0 auto; width: 20px; }
.tree-pop .tree-toggle { display: inline-flex; cursor: pointer; color: var(--muted); border-radius: var(--radius-sm); }
.tree-pop .tree-toggle:hover { background: var(--surface-2); }
.tree-pop-empty { padding: 8px; color: var(--muted); font-size: 0.85rem; }
/* In Zahlenspalten sitzt der Inhalt rechts, passend zur Ausrichtung der Spalte. */
.table td.num .cell-row { justify-content: flex-end; }
.table tbody tr:last-child td { border-bottom: none; }