From 088cfbfe66355b0b653d1a73700b4477d378a382 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 19 Aug 2026 20:03:16 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#41:=20Typeahead=20f=C3=BCr=20Kategorien?= =?UTF-8?q?=20im=20Kontoblatt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kategorie-Filter und Schnellzuordnung tragen jetzt data-kombobox: kombobox.js baut das – ihre Liste ist kurz. --- Changes.md | 14 ++ docs/buchungen.md | 14 ++ public/static/app.css | 56 +++++ public/static/kombobox.js | 306 ++++++++++++++++++++++++++++ public/static/kontoblatt.js | 9 + src/Views/buchungen/kontoblatt.php | 5 +- tests/35_KategorieTypeaheadTest.php | 123 +++++++++++ 7 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 public/static/kombobox.js create mode 100644 tests/35_KategorieTypeaheadTest.php diff --git a/Changes.md b/Changes.md index 39d3818..4559e2e 100644 --- a/Changes.md +++ b/Changes.md @@ -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 +`` 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 `` – ihre Liste +ist kurz. Ohne JavaScript bleibt es überall beim normalen `` beim Laden der Seite in ein Eingabefeld mit darunter aufklappender, gefilterter +Liste um – Optgroups bleiben als Überschriften erhalten. Das ursprüngliche `` ließe sich weder fokussieren noch mit +`showPicker()` öffnen. Die Klassen-Auswahl bleibt bewusst ein gewöhnliches ` per Typeahead bedienbar, siehe kombobox.js (Fix #41). Das + Eingabefeld tritt optisch an die Stelle des per Typeahead bedienbar machen – ein Eingabefeld + * ersetzt es optisch, darunter erscheint die nach der Eingabe gefilterte + * Liste. Das ursprüngliche arbeitet (etwa kontoblatt.js), braucht dafür keine + * Sonderbehandlung. + * + * Ohne JavaScript bleibt es beim gewöhnlichen als Gruppen – ohne Gruppe (kein + * darüber) landen die Optionen in einer Gruppe ohne Überschrift, in der + * Reihenfolge, in der sie im 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 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 ist unsichtbar und ließe sich weder + // fokussieren noch mit showPicker öffnen. Die Klassenliste bleibt ein + // gewöhnliches -