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,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useAuth } from "../auth";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
import { FIELD_TYPES, fieldTypeLabel } from "../fields";
|
import { FIELD_TYPES, fieldTypeLabel } from "../fields";
|
||||||
|
import CategorySelect from "../components/CategorySelect";
|
||||||
|
|
||||||
const MODUS = { food: "Lebensmittel", object: "Gegenstand" };
|
const MODUS = { food: "Lebensmittel", object: "Gegenstand" };
|
||||||
|
|
||||||
@@ -199,15 +200,11 @@ export default function Categories() {
|
|||||||
</td>
|
</td>
|
||||||
<td data-label="Übergeordnet">
|
<td data-label="Übergeordnet">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<select value={c.parent_id ?? ""} style={{ marginTop: 0, minWidth: 150 }}
|
<CategorySelect
|
||||||
onChange={(e) => patch(c, {
|
value={c.parent_id ?? null}
|
||||||
parent_id: e.target.value === "" ? null : Number(e.target.value),
|
nodes={parentOptions}
|
||||||
})}>
|
onChange={(pid) => patch(c, { parent_id: pid })}
|
||||||
<option value="">– oberste Ebene –</option>
|
/>
|
||||||
{parentOptions.map((o) => (
|
|
||||||
<option key={o.id} value={o.id}>{"— ".repeat(o.depth)}{o.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="muted">{c.parent_id ? nameById[c.parent_id] : "–"}</span>
|
<span className="muted">{c.parent_id ? nameById[c.parent_id] : "–"}</span>
|
||||||
)}
|
)}
|
||||||
@@ -246,15 +243,11 @@ export default function Categories() {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Untergeordnet (optional)
|
Untergeordnet (optional)
|
||||||
<select value={form.parent_id}
|
<CategorySelect
|
||||||
onChange={(e) => setForm({ ...form, parent_id: e.target.value })}>
|
value={form.parent_id === "" ? null : Number(form.parent_id)}
|
||||||
<option value="">– oberste Ebene –</option>
|
nodes={tree}
|
||||||
{tree.map((c) => (
|
onChange={(pid) => setForm({ ...form, parent_id: pid == null ? "" : String(pid) })}
|
||||||
<option key={c.id} value={c.id}>
|
/>
|
||||||
{"— ".repeat(c.depth)}{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Verwaltungsart
|
Verwaltungsart
|
||||||
|
|||||||
@@ -320,6 +320,44 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
|||||||
.tree-toggle .icon.rotated { transform: rotate(90deg); }
|
.tree-toggle .icon.rotated { transform: rotate(90deg); }
|
||||||
/* Platzhalter, damit Zeilen ohne Unterkategorien buendig zu denen mit stehen. */
|
/* Platzhalter, damit Zeilen ohne Unterkategorien buendig zu denen mit stehen. */
|
||||||
.tree-spacer { display: inline-block; flex: 0 0 auto; width: 24px; }
|
.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. */
|
/* In Zahlenspalten sitzt der Inhalt rechts, passend zur Ausrichtung der Spalte. */
|
||||||
.table td.num .cell-row { justify-content: flex-end; }
|
.table td.num .cell-row { justify-content: flex-end; }
|
||||||
.table tbody tr:last-child td { border-bottom: none; }
|
.table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
|||||||
Reference in New Issue
Block a user