diff --git a/web/src/components/GroupParentSelect.jsx b/web/src/components/GroupParentSelect.jsx new file mode 100644 index 0000000..9b79900 --- /dev/null +++ b/web/src/components/GroupParentSelect.jsx @@ -0,0 +1,146 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import Icon from "./Icon"; +import { nachfahrenIds, pfadText } from "../groupGraph"; + +/** + * Mehrfachauswahl der Obergruppen einer Gruppe. + * + * Bewusst KEIN Baum wie CategorySelect: eine Gruppe darf unter mehreren + * Obergruppen hängen („Grillwurst" unter „Wurst" UND unter „Grillgut"). Ein + * Baum müsste sie mehrfach anzeigen; eine flache, alphabetische Liste mit + * Häkchen ist ehrlicher und deutlich weniger Code. Der volle Weg steht als + * Titel an jeder Zeile, damit gleichnamige Äste unterscheidbar bleiben. + * + * Die eigene Gruppe und ihre Untergruppen sind gesperrt – sie würden einen Ring + * erzeugen. Der Server lehnt das ohnehin ab; hier wird es vorher sichtbar. + * + * Das Menü hängt per Portal am
, damit es der horizontal scrollende + * Tabellenrahmen nicht abschneidet (wie bei CategorySelect). + */ +export default function GroupParentSelect({ + value = [], + onChange, + groups = [], + selfId = null, + disabled = false, +}) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState(null); + const [filter, setFilter] = useState(""); + const btnRef = useRef(null); + const popRef = useRef(null); + + const gewaehlt = new Set(value || []); + const gesperrt = selfId == null ? new Set() : new Set([selfId, ...nachfahrenIds(groups, selfId)]); + const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name])); + + const sichtbar = groups + .filter((g) => !filter || g.name.toLowerCase().includes(filter.toLowerCase())) + .slice() + .sort((a, b) => a.name.localeCompare(b.name, "de")); + + const label = value?.length + ? value.map((id) => nameById[id]).filter(Boolean).join(", ") + : "– keine –"; + + 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 onScroll(e) { + if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return; + setOpen(false); + } + function onResize() { setOpen(false); } + function onKey(e) { if (e.key === "Escape") setOpen(false); } + document.addEventListener("mousedown", onDoc); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", onResize); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", onResize); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + // Bei Mehrfachauswahl bleibt das Menü offen – sonst müsste man es für jede + // weitere Obergruppe neu aufklappen. + function umschalten(id) { + const neu = new Set(gewaehlt); + if (neu.has(id)) neu.delete(id); + else neu.add(id); + onChange([...neu]); + } + + return ( + <> + + + {open && pos && createPortal( +