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.
306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
/* 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);
|
||
})();
|