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 # Ä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) ## Kontoblatt: Schnellzuordnung ohne Neuladen (19.08.2026)
Das Ändern von Kategorie oder Klasse im Kontoblatt lud bisher die ganze Seite neu, um die 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 im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die
Buchungsmaske. 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 ## Klasse im Kontoblatt
Die Klassenspalte ist nach derselben Bauart anklickbar: eine zweite Liste 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 select { max-width: 15rem; }
.filterleiste label { font-size: .88rem; color: var(--text-gedaempft); } .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 { font-size: .89rem; }
table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 5%, transparent); } 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); feld.parentNode.appendChild(formular);
formular.hidden = false; 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(); auswahlliste.focus();
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall. // Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
try { 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>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> <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> <option value="">Alle Kategorien</option>
<?php foreach ($kategorien as $k): ?> <?php foreach ($kategorien as $k): ?>
<option value="<?= (int)$k['id'] ?>" <option value="<?= (int)$k['id'] ?>"
@ -363,7 +363,7 @@ include __DIR__ . '/../layout/kopf.php';
ebenso viele vollständige Kategoriebäume. */ ?> ebenso viele vollständige Kategoriebäume. */ ?>
<form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden> <form method="post" action="" id="zuordnungsauswahl" class="zuordnungsauswahl" hidden>
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <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 $gewaehlt = ''; $mitSplit = true; $mitDepot = true; ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?> <?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select> </select>
@ -500,6 +500,7 @@ include __DIR__ . '/../layout/kopf.php';
<script src="/static/betrag.js" defer></script> <script src="/static/betrag.js" defer></script>
<script src="/static/wertpapierbuchung.js" defer></script> <script src="/static/wertpapierbuchung.js" defer></script>
<script src="/static/kombobox.js" defer></script>
<script src="/static/kontoblatt.js" defer></script> <script src="/static/kontoblatt.js" defer></script>
<?php endif; ?> <?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();
}
}