Finanzmanager/public/static/kombobox.js
fds2610-bot 088cfbfe66 Fix #41: Typeahead für Kategorien im Kontoblatt
Kategorie-Filter und Schnellzuordnung tragen jetzt data-kombobox: kombobox.js
baut das <select> beim Laden in ein Eingabefeld mit gefilterter, aufklappender
Liste um, ohne Verhalten oder Markup für vorhandenen Code (kontoblatt.js) zu
ändern. Die Klassen-Auswahl bleibt bewusst ein gewöhnliches <select> – ihre
Liste ist kurz.
2026-08-19 20:03:16 +02:00

306 lines
11 KiB
JavaScript
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.

/* 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);
})();