Files
certain/web/src/components/AccountSelect.jsx
T
GuidoandClaude Opus 5 78835d1e40 feat: Konto-Auswahl mit Symbol und Farbe
Das native <select> zeigte nur den Namen im Systemstil. Bei einem Dutzend
Konten erkennt man seines am Symbol deutlich schneller – und beim Import
sieht man sofort, ob man in das gemeinte Konto liest.

Neue Komponente AccountSelect nach dem Muster von CategorySelect, mit
denselben Klassen und per Portal gerendert. Eingesetzt beim Import, in
Filter und Formular der Buchungen sowie beim Zielkonto eines Uebertrags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:42:14 +02:00

134 lines
4.6 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, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
import IconChip from "./IconChip";
// Konto-Auswahl mit Symbol und Kontofarbe. Das native <select> zeigte nur den
// Namen im Systemstil; bei einem Dutzend Konten erkennt man seines am Symbol
// deutlich schneller. Nutzt dieselben Klassen wie CategorySelect und wird per
// Portal gerendert, damit es in Tabellen nicht abgeschnitten wird.
export default function AccountSelect({
accounts = [],
value,
onChange,
allowNone = false,
noneLabel = "—",
placeholder = "—",
showCurrency = true,
minWidth = 0,
disabled = false,
}) {
const { t } = useTranslation();
const selectedId = value === "" || value == null ? null : Number(value);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const btnRef = useRef(null);
const panelRef = useRef(null);
const sel = accounts.find((a) => a.id === selectedId) || null;
function computePos() {
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const width = Math.max(r.width, 240);
const spaceBelow = window.innerHeight - r.bottom;
const spaceAbove = r.top;
const desired = 320;
let top;
let maxHeight;
if (spaceBelow >= 200 || 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 });
}
useEffect(() => {
if (!open) return undefined;
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);
// Scrollen IN der Liste darf sie nicht schliessen – nur Scrollen daneben.
const onScrollResize = (e) => {
if (e?.target && panelRef.current?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
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);
}
const zeile = (a) => (
<span className="catsel-value">
<IconChip name={a.icon} color={a.color} size={22} />
<span className="ellipsis">{a.name}{showCurrency ? ` (${a.currency})` : ""}</span>
</span>
);
return (
<div className="catsel" style={{ minWidth }}>
<button
type="button"
ref={btnRef}
className={`catsel-btn${open ? " open" : ""}`}
onClick={() => (open ? setOpen(false) : (computePos(), setOpen(true)))}
disabled={disabled}
>
{sel ? zeile(sel) : <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-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>
)}
{accounts.map((a) => (
<button type="button" key={a.id}
className={`catsel-opt${a.id === selectedId ? " selected" : ""}`}
onClick={() => choose(a.id)}>
{zeile(a)}
{a.id === selectedId && <Icon name="check" size={15} className="catsel-check" />}
</button>
))}
{accounts.length === 0 && (
<div className="catsel-empty muted">{t("accounts.empty")}</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}