Files
certain/web/src/components/CategorySelect.jsx
T
GuidoandClaude Opus 5 ff897c196b fix: Dropdown bleibt beim Scrollen offen, Dialog beim Markieren
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>
2026-08-21 20:31:18 +02:00

205 lines
7.1 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}