Zwei Klick-Aergernisse mit derselben Ursache – ein zu grob gefasster Schliess-Ausloeser. Die Kategorieliste schloss bei jedem Scroll-Ereignis, wegen capture=true auch beim Scrollen IN der Liste. Genau das will man aber tun. Jetzt zaehlt nur noch Scrollen ausserhalb. Der Dialog schloss, sobald ein Klick auf dem Hintergrund endete. Markiert man Text in einem Feld und zieht ueber den Rand hinaus, liegt das Mouseup dort – der Dialog ging mitten im Markieren zu. Jetzt muss der Klick auch auf dem Hintergrund begonnen haben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
205 lines
7.1 KiB
React
205 lines
7.1 KiB
React
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import Icon from "./Icon";
|
||
import IconChip from "./IconChip";
|
||
|
||
// Auf-/zuklappbares Kategorie-Dropdown (Gruppe -> Kategorien als Baum) mit
|
||
// Suchfeld. Ersetzt das native <select> mit optgroup, das die oberste Gruppe
|
||
// abgesetzt wirken ließ. Das Panel wird per Portal mit fester Position gerendert,
|
||
// damit es in scrollbaren Tabellen (Import-Prüfansicht) nicht abgeschnitten wird.
|
||
export default function CategorySelect({
|
||
groups = [],
|
||
value,
|
||
onChange,
|
||
allowNone = false,
|
||
noneLabel = "—",
|
||
placeholder = "—",
|
||
minWidth = 0,
|
||
disabled = false,
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const selectedId = value === "" || value == null ? null : Number(value);
|
||
|
||
const [open, setOpen] = useState(false);
|
||
const [q, setQ] = useState("");
|
||
const [collapsed, setCollapsed] = useState(() => new Set());
|
||
const [pos, setPos] = useState(null);
|
||
|
||
const btnRef = useRef(null);
|
||
const panelRef = useRef(null);
|
||
const searchRef = useRef(null);
|
||
|
||
const byId = useMemo(() => {
|
||
const m = {};
|
||
for (const g of groups) for (const c of g.categories) m[c.id] = c;
|
||
return m;
|
||
}, [groups]);
|
||
const sel = selectedId != null ? byId[selectedId] : null;
|
||
|
||
function computePos() {
|
||
const el = btnRef.current;
|
||
if (!el) return;
|
||
const r = el.getBoundingClientRect();
|
||
const width = Math.max(r.width, 260);
|
||
const spaceBelow = window.innerHeight - r.bottom;
|
||
const spaceAbove = r.top;
|
||
const desired = 340;
|
||
let top;
|
||
let maxHeight;
|
||
if (spaceBelow >= 220 || spaceBelow >= spaceAbove) {
|
||
maxHeight = Math.min(desired, spaceBelow - 10);
|
||
top = r.bottom + 4;
|
||
} else {
|
||
maxHeight = Math.min(desired, spaceAbove - 10);
|
||
top = r.top - 4 - maxHeight;
|
||
}
|
||
let left = r.left;
|
||
if (left + width > window.innerWidth - 8) left = window.innerWidth - 8 - width;
|
||
if (left < 8) left = 8;
|
||
setPos({ left, top, width, maxHeight });
|
||
}
|
||
|
||
function openPanel() {
|
||
if (disabled) return;
|
||
computePos();
|
||
setQ("");
|
||
setOpen(true);
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined;
|
||
searchRef.current?.focus();
|
||
const onDown = (e) => {
|
||
if (panelRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
|
||
setOpen(false);
|
||
};
|
||
const onKey = (e) => e.key === "Escape" && setOpen(false);
|
||
// Beim Scrollen der Seite wandert die Liste weg vom Knopf, deshalb zu.
|
||
// Scrollen INNERHALB der Liste ist aber genau das, was man tun will –
|
||
// capture=true fing das bisher mit ab und schloss sie sofort wieder.
|
||
const onScrollResize = (e) => {
|
||
if (e?.target && panelRef.current?.contains(e.target)) return;
|
||
setOpen(false);
|
||
};
|
||
document.addEventListener("mousedown", onDown);
|
||
document.addEventListener("keydown", onKey);
|
||
// capture=true: fängt auch das Scrollen innerhalb der Tabelle ab.
|
||
window.addEventListener("scroll", onScrollResize, true);
|
||
window.addEventListener("resize", onScrollResize);
|
||
return () => {
|
||
document.removeEventListener("mousedown", onDown);
|
||
document.removeEventListener("keydown", onKey);
|
||
window.removeEventListener("scroll", onScrollResize, true);
|
||
window.removeEventListener("resize", onScrollResize);
|
||
};
|
||
}, [open]);
|
||
|
||
function choose(id) {
|
||
onChange(id);
|
||
setOpen(false);
|
||
}
|
||
function toggleGroup(id) {
|
||
setCollapsed((prev) => {
|
||
const next = new Set(prev);
|
||
next.has(id) ? next.delete(id) : next.add(id);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
const ql = q.trim().toLowerCase();
|
||
|
||
return (
|
||
<div className="catsel" style={{ minWidth }}>
|
||
<button
|
||
type="button"
|
||
ref={btnRef}
|
||
className={`catsel-btn${open ? " open" : ""}`}
|
||
onClick={() => (open ? setOpen(false) : openPanel())}
|
||
disabled={disabled}
|
||
>
|
||
{sel ? (
|
||
<span className="catsel-value">
|
||
<IconChip name={sel.icon} color={sel.color} size={22} />
|
||
<span className="ellipsis">{sel.name}</span>
|
||
</span>
|
||
) : (
|
||
<span className="catsel-value muted ellipsis">{placeholder}</span>
|
||
)}
|
||
<Icon name="chevron-down" size={15} className="catsel-caret" />
|
||
</button>
|
||
|
||
{open &&
|
||
pos &&
|
||
createPortal(
|
||
<div
|
||
ref={panelRef}
|
||
className="catsel-panel"
|
||
style={{ left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight }}
|
||
>
|
||
<div className="catsel-search">
|
||
<Icon name="search" size={14} />
|
||
<input
|
||
ref={searchRef}
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
placeholder={t("categories.searchPlaceholder")}
|
||
/>
|
||
</div>
|
||
<div className="catsel-list">
|
||
{allowNone && (
|
||
<button
|
||
type="button"
|
||
className={`catsel-opt${selectedId == null ? " selected" : ""}`}
|
||
onClick={() => choose(null)}
|
||
>
|
||
<span className="muted ellipsis">{noneLabel}</span>
|
||
{selectedId == null && <Icon name="check" size={15} className="catsel-check" />}
|
||
</button>
|
||
)}
|
||
{groups.map((g) => {
|
||
const cats = ql
|
||
? g.categories.filter((c) => c.name.toLowerCase().includes(ql))
|
||
: g.categories;
|
||
if (ql && cats.length === 0) return null;
|
||
const isCollapsed = !ql && collapsed.has(g.id);
|
||
return (
|
||
<div className="catsel-group" key={g.id}>
|
||
<button
|
||
type="button"
|
||
className="catsel-group-head"
|
||
onClick={() => toggleGroup(g.id)}
|
||
>
|
||
<Icon
|
||
name="chevron-down"
|
||
size={14}
|
||
className={`catsel-gchev${isCollapsed ? " collapsed" : ""}`}
|
||
/>
|
||
<IconChip name={g.icon} color={g.color} size={18} />
|
||
<span className="ellipsis">{g.name}</span>
|
||
</button>
|
||
{!isCollapsed &&
|
||
cats.map((c) => (
|
||
<button
|
||
type="button"
|
||
key={c.id}
|
||
className={`catsel-opt${selectedId === c.id ? " selected" : ""}`}
|
||
onClick={() => choose(c.id)}
|
||
>
|
||
<IconChip name={c.icon} color={c.color} size={20} />
|
||
<span className="ellipsis">{c.name}</span>
|
||
{selectedId === c.id && <Icon name="check" size={15} className="catsel-check" />}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
{groups.length === 0 && <div className="catsel-empty muted small">{t("categories.emptyGroup")}</div>}
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
}
|