Merge pull request 'Fix #41: Typeahead für Kategorien im Kontoblatt' (#44) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/44
This commit is contained in:
fds2610 2026-08-19 20:15:07 +02:00
commit f737db6182
7 changed files with 525 additions and 2 deletions

View file

@ -1,5 +1,19 @@
# Änderungen
## Kontoblatt: Typeahead für die Kategorie (19.08.2026)
Die Kategorie ließ sich im Kontoblatt nur über eine lange Auswahlliste finden – bei vielen
Kategorien ein langes Blättern (Feature #41). Ein neues `kombobox.js` baut ein markiertes
`<select data-kombobox>` beim Laden der Seite in ein Eingabefeld mit darunter aufklappender,
gefilterter Liste um; Gruppen (Optgroups) und die Reihenfolge der Optionen bleiben erhalten.
Das ursprüngliche `<select>` bleibt unsichtbar im Formular stehen, damit Wert, Name und das
Ändern-Ereignis für vorhandenen Code unverändert bleiben.
Betroffen sind der Kategorie-Filter oben im Kontoblatt und die Schnellzuordnung je Buchung
(`kontoblatt.js` öffnet dort die Kombobox statt `showPicker()`, sobald ein `<select>`
umgebaut wurde). Die Klassen-Auswahl bleibt bewusst ein gewöhnliches `<select>` – ihre Liste
ist kurz. Ohne JavaScript bleibt es überall beim normalen `<select>`.
## Kontoblatt: Schnellzuordnung ohne Neuladen (19.08.2026)
Das Ändern von Kategorie oder Klasse im Kontoblatt lud bisher die ganze Seite neu, um die

View file

@ -114,6 +114,20 @@ gewesen. Wer eine Aufteilung wieder in eine einzelne Zuordnung wandeln will, lö
im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die
Buchungsmaske.
### Typeahead (Feature #41)
Sowohl der Kategorie-Filter oben im Kontoblatt als auch die Auswahlliste der
Schnellzuordnung tragen `data-kombobox`. `static/kombobox.js` baut ein so markiertes
`<select>` beim Laden der Seite in ein Eingabefeld mit darunter aufklappender, gefilterter
Liste um – Optgroups bleiben als Überschriften erhalten. Das ursprüngliche `<select>`
bleibt unsichtbar im Formular stehen, Wert, Name und `change`-Ereignis ändern sich nicht;
`kontoblatt.js` liest und setzt weiterhin nur `select.value`.
Beim Öffnen der Schnellzuordnung ruft `kontoblatt.js` `select._kombobox.oeffnen()` statt
`showPicker()` auf – ein verstecktes `<select>` ließe sich weder fokussieren noch mit
`showPicker()` öffnen. Die Klassen-Auswahl bleibt bewusst ein gewöhnliches `<select>` ohne
`data-kombobox`: Ihre Liste ist kurz, ein Typeahead brächte dort nichts.
## Klasse im Kontoblatt
Die Klassenspalte ist nach derselben Bauart anklickbar: eine zweite Liste

View file

@ -1085,6 +1085,62 @@ tr.zugeklappt { display: none; }
.filterleiste select { max-width: 15rem; }
.filterleiste label { font-size: .88rem; color: var(--text-gedaempft); }
/* Kombobox: <select> per Typeahead bedienbar, siehe kombobox.js (Fix #41). Das
Eingabefeld tritt optisch an die Stelle des <select>, darunter klappt die
gefilterte Liste auf. Größe und Rahmen übernimmt die Kombobox von dem
Feldtyp, in dem sie steht – Filterleiste und Schnellzuordnung sehen sonst
unterschiedlich groß aus. */
.kombobox { position: relative; display: inline-block; }
.kombobox-eingabe {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--rand);
border-radius: 4px;
background: var(--flaeche);
color: var(--text);
font: inherit;
}
.kombobox-liste {
position: absolute;
z-index: 20;
top: 100%;
left: 0;
right: 0;
margin: .2rem 0 0;
padding: .25rem 0;
max-height: 16rem;
overflow-y: auto;
list-style: none;
border: 1px solid var(--rand-kraeftig);
border-radius: var(--radius);
background: var(--flaeche);
box-shadow: 0 6px 18px rgb(0 0 0 / 20%);
}
.kombobox-gruppe {
padding: .2rem .6rem;
color: var(--text-gedaempft);
font-size: .78rem;
font-weight: 600;
}
.kombobox-option { padding: .25rem .7rem; cursor: pointer; }
.kombobox-option.aktiv { background: var(--flaeche-gedaempft); }
.kombobox-leer { padding: .25rem .7rem; color: var(--text-gedaempft); }
.filterleiste .kombobox { max-width: 15rem; }
.filterleiste .kombobox-eingabe { padding: .3rem .45rem; font-size: .9rem; }
.zuordnungsauswahl .kombobox { width: 100%; max-width: 13rem; }
.zuordnungsauswahl .kombobox-eingabe {
height: 1.35rem;
padding: 0 .2rem;
border-color: var(--akzent);
font-size: .85rem;
}
table.kontoblatt { font-size: .89rem; }
table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 5%, transparent); }

306
public/static/kombobox.js Normal file
View file

@ -0,0 +1,306 @@
/* Kombobox: ein <select> per Typeahead bedienbar machen – ein Eingabefeld
* ersetzt es optisch, darunter erscheint die nach der Eingabe gefilterte
* Liste. Das ursprüngliche <select> bleibt im Formular stehen (unsichtbar):
* Wert, Name und das Ändern-Ereignis bleiben dieselben, jeder Code, der bisher
* mit dem <select> arbeitet (etwa kontoblatt.js), braucht dafür keine
* Sonderbehandlung.
*
* Ohne JavaScript bleibt es beim gewöhnlichen <select> – dieses Skript
* ersetzt nichts, wenn es nicht läuft.
*/
(function () {
'use strict';
let naechsteId = 0;
/* Die Einträge eines <select> als Gruppen – ohne Gruppe (kein <optgroup>
* darüber) landen die Optionen in einer Gruppe ohne Überschrift, in der
* Reihenfolge, in der sie im <select> stehen. */
function modellAus(select) {
const gruppen = [];
let offeneGruppe = null;
Array.from(select.children).forEach(function (kind) {
if (kind.tagName === 'OPTGROUP') {
gruppen.push({
label: kind.label,
optionen: Array.from(kind.children).map(eintragAus)
});
offeneGruppe = null;
return;
}
if (kind.tagName !== 'OPTION') {
return;
}
if (!offeneGruppe) {
offeneGruppe = { label: null, optionen: [] };
gruppen.push(offeneGruppe);
}
offeneGruppe.optionen.push(eintragAus(kind));
});
return gruppen;
}
function eintragAus(option) {
return { wert: option.value, text: option.textContent.trim() };
}
function passt(text, filter) {
return text.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1;
}
/**
* Baut die Kombobox um `select` herum auf. Ein zweiter Aufruf für dasselbe
* <select> liefert dieselbe Instanz zurück, statt sie doppelt anzulegen.
*/
function verbessern(select) {
if (select._kombobox) {
return select._kombobox;
}
const gruppen = modellAus(select);
const alleEintraege = gruppen.reduce(function (liste, gruppe) {
return liste.concat(gruppe.optionen);
}, []);
const id = 'kombobox-' + (++naechsteId);
const wrapper = document.createElement('div');
wrapper.className = 'kombobox';
const eingabe = document.createElement('input');
eingabe.type = 'text';
eingabe.className = 'kombobox-eingabe';
eingabe.autocomplete = 'off';
eingabe.spellcheck = false;
eingabe.setAttribute('role', 'combobox');
eingabe.setAttribute('aria-haspopup', 'listbox');
eingabe.setAttribute('aria-expanded', 'false');
eingabe.setAttribute('aria-autocomplete', 'list');
eingabe.setAttribute('aria-controls', id);
const beschriftung = select.getAttribute('aria-label');
if (beschriftung) {
eingabe.setAttribute('aria-label', beschriftung);
}
const liste = document.createElement('ul');
liste.className = 'kombobox-liste';
liste.id = id;
liste.setAttribute('role', 'listbox');
liste.hidden = true;
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(eingabe);
wrapper.appendChild(liste);
wrapper.appendChild(select);
select.hidden = true;
let offen = false;
let aktivIndex = -1;
function optionenElemente() {
return Array.from(liste.querySelectorAll('.kombobox-option'));
}
function aktivSetzen(index) {
const elemente = optionenElemente();
if (elemente.length === 0) {
aktivIndex = -1;
eingabe.removeAttribute('aria-activedescendant');
return;
}
aktivIndex = Math.max(0, Math.min(index, elemente.length - 1));
elemente.forEach(function (el, i) {
const aktiv = i === aktivIndex;
el.classList.toggle('aktiv', aktiv);
el.setAttribute('aria-selected', aktiv ? 'true' : 'false');
});
const aktivesElement = elemente[aktivIndex];
eingabe.setAttribute('aria-activedescendant', aktivesElement.id);
aktivesElement.scrollIntoView({ block: 'nearest' });
}
function rendern(filterText) {
liste.innerHTML = '';
let laufendeNummer = 0;
gruppen.forEach(function (gruppe) {
const treffer = gruppe.optionen.filter(function (eintrag) {
return passt(eintrag.text, filterText);
});
if (treffer.length === 0) {
return;
}
if (gruppe.label) {
const kopf = document.createElement('li');
kopf.className = 'kombobox-gruppe';
kopf.setAttribute('role', 'presentation');
kopf.textContent = gruppe.label;
liste.appendChild(kopf);
}
treffer.forEach(function (eintrag) {
const punkt = document.createElement('li');
punkt.className = 'kombobox-option';
punkt.id = id + '-' + (++laufendeNummer);
punkt.setAttribute('role', 'option');
punkt.dataset.wert = eintrag.wert;
punkt.textContent = eintrag.text;
liste.appendChild(punkt);
});
});
if (liste.children.length === 0) {
const leer = document.createElement('li');
leer.className = 'kombobox-leer';
leer.setAttribute('role', 'presentation');
leer.textContent = 'Keine Treffer';
liste.appendChild(leer);
}
const elemente = optionenElemente();
if (elemente.length === 0) {
aktivIndex = -1;
eingabe.removeAttribute('aria-activedescendant');
return;
}
const vorauswahl = filterText.trim() === ''
? elemente.findIndex(function (el) { return el.dataset.wert === select.value; })
: 0;
aktivSetzen(vorauswahl === -1 ? 0 : vorauswahl);
}
function offenAnzeigen() {
liste.hidden = false;
eingabe.setAttribute('aria-expanded', 'true');
offen = true;
}
function schliessen() {
liste.hidden = true;
eingabe.setAttribute('aria-expanded', 'false');
eingabe.removeAttribute('aria-activedescendant');
offen = false;
}
/* Zeigt die Bezeichnung des gewählten Werts im Eingabefeld – ohne
* Auswahl bleibt es leer, wie es die erste Option („— keine —“ /
* „Alle Kategorien“) mit dem Wert '' vorsieht. */
function synchronisieren() {
const eintrag = alleEintraege.find(function (e) { return e.wert === select.value; });
eingabe.value = eintrag ? eintrag.text : '';
}
function waehlen(punkt) {
const wertVorher = select.value;
select.value = punkt.dataset.wert;
eingabe.value = punkt.textContent.trim();
schliessen();
if (select.value !== wertVorher) {
select.dispatchEvent(new Event('change', { bubbles: true }));
}
eingabe.focus();
}
/* Öffnet die Liste ungefiltert mit Fokus im Eingabefeld – der Aufruf
* von außen (kontoblatt.js), wenn die Kombobox an die Stelle eines
* angeklickten Feldes tritt. Der Text steht dabei markiert, damit
* Tippen ihn sofort ersetzt, wie es ein Klick auf ein <select> auch
* täte. */
function oeffnenUndFokussieren() {
synchronisieren();
rendern('');
offenAnzeigen();
eingabe.focus();
eingabe.select();
}
// Ungefiltert, wie ein <select> beim Öffnen auch alle Optionen zeigt –
// gefiltert wird erst, sobald tatsächlich getippt wird (unten).
eingabe.addEventListener('focus', function () {
rendern('');
offenAnzeigen();
});
eingabe.addEventListener('input', function () {
rendern(eingabe.value);
offenAnzeigen();
});
eingabe.addEventListener('keydown', function (ereignis) {
if (ereignis.key === 'ArrowDown') {
ereignis.preventDefault();
if (!offen) {
// Wieder aufklappen (etwa nach Escape) zeigt erneut alles –
// gefiltert wird erst wieder durch neues Tippen.
rendern('');
offenAnzeigen();
return;
}
aktivSetzen(aktivIndex + 1);
return;
}
if (ereignis.key === 'ArrowUp') {
ereignis.preventDefault();
if (offen) {
aktivSetzen(aktivIndex - 1);
}
return;
}
if (ereignis.key === 'Enter') {
if (!offen) {
return;
}
const punkt = optionenElemente()[aktivIndex];
if (punkt) {
ereignis.preventDefault();
waehlen(punkt);
}
return;
}
if (ereignis.key === 'Escape' && offen) {
ereignis.preventDefault();
schliessen();
synchronisieren();
}
});
eingabe.addEventListener('blur', function () {
schliessen();
synchronisieren();
});
/* mousedown statt click, mit preventDefault: Sonst verlöre das
* Eingabefeld vor dem Klick den Fokus, und der blur-Handler schlösse
* die Liste, bevor die Auswahl ankommt. */
liste.addEventListener('mousedown', function (ereignis) {
const punkt = ereignis.target.closest('.kombobox-option');
if (!punkt) {
return;
}
ereignis.preventDefault();
waehlen(punkt);
});
synchronisieren();
select._kombobox = {
synchronisieren: synchronisieren,
oeffnen: oeffnenUndFokussieren
};
return select._kombobox;
}
window.FmKombobox = { verbessern: verbessern };
// Jedes so markierte <select> wird beim Laden der Seite umgebaut – ohne
// dass die einbindende Ansicht dafür eigenen Code braucht.
document.querySelectorAll('select[data-kombobox]').forEach(verbessern);
})();

View file

@ -109,6 +109,15 @@
feld.parentNode.appendChild(formular);
formular.hidden = false;
// Die Kategorieliste ist per kombobox.js eine Typeahead-Kombobox
// (Fix #41) – ihr <select> ist unsichtbar und ließe sich weder
// fokussieren noch mit showPicker öffnen. Die Klassenliste bleibt ein
// gewöhnliches <select>.
if (auswahlliste._kombobox) {
auswahlliste._kombobox.oeffnen();
return;
}
auswahlliste.focus();
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
try {

View file

@ -161,7 +161,7 @@ include __DIR__ . '/../layout/kopf.php';
<label>von <input type="date" name="von" value="<?= View::h($filter['von'] ?? '') ?>"></label>
<label>bis <input type="date" name="bis" value="<?= View::h($filter['bis'] ?? '') ?>"></label>
<select name="kategorie_id">
<select name="kategorie_id" aria-label="Kategorie" data-kombobox>
<option value="">Alle Kategorien</option>
<?php foreach ($kategorien as $k): ?>
<option value="<?= (int)$k['id'] ?>"
@ -363,7 +363,7 @@ include __DIR__ . '/../layout/kopf.php';
ebenso viele vollständige Kategoriebäume. */ ?>
<form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden>
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto">
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto" data-kombobox>
<?php $gewaehlt = ''; $mitSplit = true; $mitDepot = true; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
@ -500,6 +500,7 @@ include __DIR__ . '/../layout/kopf.php';
<script src="/static/betrag.js" defer></script>
<script src="/static/wertpapierbuchung.js" defer></script>
<script src="/static/kombobox.js" defer></script>
<script src="/static/kontoblatt.js" defer></script>
<?php endif; ?>

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
/**
* Fix #41: Die Kategorie lässt sich im Kontoblatt per Typeahead eingeben,
* statt in einer langen Auswahlliste zu blättern – sowohl im Filter oben als
* auch in der Schnellzuordnung je Buchung. kombobox.js baut daraus im
* Browser ein Eingabefeld mit gefilterter Liste; geprüft wird hier nur, dass
* das gerenderte Kontoblatt die dafür nötigen Marken trägt und das Skript
* einbindet.
*/
class KategorieTypeaheadTest extends TestCase
{
protected string $testName = 'Test 35: Typeahead für Kategorien im Kontoblatt';
private int $konto = 0;
public function run(): void
{
$this->konto = Konto::anlegen([
'name' => 'Typeahead Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
Kategorie::anlegen(['name' => 'Typeahead Einkauf', 'art' => 'ausgabe']);
Klasse::anlegen('Typeahead Privat');
$html = $this->kontoblattHtml();
$this->filterSelectTraegtDieMarke($html);
$this->zuordnungSelectTraegtDieMarke($html);
$this->klasseBleibtEinGewoehnlichesSelect($html);
$this->skriptWirdVorKontoblattJsGeladen($html);
}
private function filterSelectTraegtDieMarke(string $html): void
{
$this->assert(
(bool)preg_match('#<select name="kategorie_id" aria-label="Kategorie" data-kombobox>#', $html),
'Der Kategorie-Filter ist als Kombobox markiert und trägt eine Beschriftung fürs Eingabefeld'
);
}
private function zuordnungSelectTraegtDieMarke(string $html): void
{
$this->assert(
(bool)preg_match(
'#<select name="zuordnung" aria-label="Kategorie oder Gegenkonto" data-kombobox>#',
$html
),
'Die Schnellzuordnung ist ebenfalls als Kombobox markiert'
);
}
/**
* Die Klassen-Auswahl ist keine Kategorie und bleibt bewusst ein
* gewöhnliches <select> – die Liste ist kurz, ein Typeahead brächte dort
* nichts.
*/
private function klasseBleibtEinGewoehnlichesSelect(string $html): void
{
$this->assert(
(bool)preg_match('#<select name="klasse_id" aria-label="Klasse">#', $html),
'Die Klassen-Auswahl trägt keine Kombobox-Marke'
);
}
private function skriptWirdVorKontoblattJsGeladen(string $html): void
{
$kombobox = strpos($html, 'kombobox.js');
$kontoblatt = strpos($html, 'kontoblatt.js');
$this->assert($kombobox !== false, 'kombobox.js wird eingebunden');
$this->assert($kontoblatt !== false, 'kontoblatt.js wird eingebunden');
$this->assert(
$kombobox !== false && $kontoblatt !== false && $kombobox < $kontoblatt,
'kombobox.js lädt vor kontoblatt.js, das sich beim Laden darauf verlässt'
);
}
/**
* Eine View rendern wie der Controller es täte – ohne Webserver.
*/
private function kontoblattHtml(): string
{
$zeilen = [[
'id' => 1, 'datum' => '2026-05-01', 'valuta' => null, 'betrag' => -10.0,
'empfaenger' => 'Zeile', 'buchungstext' => '', 'nummer' => '', 'abgeglichen' => 0,
'kategorie_id' => null, 'klasse_id' => null, 'gesplittet' => 0, 'quelle' => 'manuell',
'bargeld_status' => null, 'bargeld_hinweis' => null, 'gegenkonto_id' => null,
'konto_id' => $this->konto, 'gegensicht' => false, 'split_id' => null, 'saldo' => 100.0,
]];
$daten = [
'konto' => Konto::mitSalden(Konto::find($this->konto)),
'zeilen' => $zeilen,
'gesamt' => count($zeilen),
'seite' => 1,
'seiten' => 1,
'filter' => [],
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
'splits' => [],
'wertpapiere' => [],
'bargeld' => false,
'titel' => 'Test',
'flash' => [],
'benutzer' => null,
'csrfToken' => 'x',
'aktuellerPfad' => '/konten/' . $this->konto,
];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/buchungen/kontoblatt.php';
return (string)ob_get_clean();
}
}