Finanzmanager/public/static/kombobox.js
fds2610-bot c58837dee8 Fix #124: Umbuchungskonten in jeder Kategorieauswahl
Unter den Kategorien stehen überall die aktiven Umbuchungskonten, je
Kontengruppe gruppiert und in deren Reihenfolge (Konto::umbuchungsgruppen()):
Regeln samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige
Buchungen, Zahlungsverkehr, Verträge sowie die Filter von Buchungsbericht
und Export. Die Masken nutzen dafür die gemeinsame Liste Kategorie /
Gegenkonto der Buchungsmaske; das getrennte Gegenkonto-Feld der Regel
entfällt.

Regel-Splitzeilen können auf ein Umbuchungskonto zeigen. Zahlungsaufträge
und Verträge bekommen ein Gegenkonto (Migration 030). Ein schon gewähltes
inaktives Konto bleibt sichtbar, damit keine Zuordnung verloren geht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:30:44 +02:00

309 lines
11 KiB
JavaScript
Raw Permalink 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;
}
/* Ein verborgener Eintrag (etwa ein inaktives Umbuchungskonto) bleibt als
* Wert gültig, erscheint in der Liste aber nur, solange er gewählt ist. */
function eintragAus(option) {
return { wert: option.value, text: option.textContent.trim(), verborgen: option.hidden };
}
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 (!eintrag.verborgen || eintrag.wert === select.value)
&& 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);
})();