Files
Smart-Dashboard/js/solar/auswahl.js
T
adminandClaude Opus 5 d36989ae0d Eigene Auswahllisten ueberall: dunkel und ueber der Seite statt im Fluss
Chrome zeichnet die Liste eines <select> hell auf grau und laesst sich davon
nicht abbringen. js/solar/auswahl.js ergaenzt jedes <select> um einen eigenen
Knopf; die Liste haengt an <body> und liegt ueber der Seite, damit ein Modal
nicht hoeher wird und der Inhalt nicht springt. Ab neun Eintraegen mit
Suchfeld. Der eigene Picker im Raumfenster faellt dafuer weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:55:17 +02:00

270 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.
/*
* Eine Auswahlliste fuer die ganze Anwendung.
*
* Die Liste, die der Browser zu einem <select> aufmacht, zeichnet er selbst -
* bei Chrome hell auf grau, mitten in einer dunklen Seite. Gestalten laesst
* sie sich nicht. Deshalb bekommt hier jedes <select> seinen eigenen Knopf;
* das <select> bleibt stehen und haelt weiterhin den Wert, sodass jeder
* vorhandene Zuhoerer (onchange, Formulare, dataset) unveraendert
* funktioniert. Wer den Wert im Code setzt, ruft auswahlAktualisieren(select)
* oder loest "change" aus - beides zieht die Beschriftung nach.
*
* Die aufgeklappte Liste haengt an <body> und liegt mit position:fixed ueber
* der Seite. Das ist der zweite Grund fuer dieses Skript: eine Liste im Fluss
* des Modals machte es hoeher, und der ganze Inhalt sprang.
*
* Neue <select> im Dokument werden von selbst uebernommen (MutationObserver),
* auch die, die der Automatik-Editor bei jedem Zeichnen neu baut.
*
* Ausnahmen: <select multiple>, <select size="2"> und alles mit
* data-auswahl="aus".
*/
const AUSWAHL_SUCHE_AB = 9; // ab so vielen Eintraegen ein Suchfeld
const AUSWAHL_MIN_BREITE = 190;
const AUSWAHL_MAX_HOEHE = 300;
let auswahlOffen = null; // { select, knopf, panel, eintraege }
/** Die Beschriftung des Knopfes aus dem gewaehlten Eintrag nachziehen. */
function auswahlAktualisieren(select) {
const huelle = select.closest(".aw");
if (!huelle) return;
const gewaehlt = select.selectedOptions[0];
const text = huelle.querySelector(".aw-wert");
text.textContent = gewaehlt ? gewaehlt.textContent : "–";
text.classList.toggle("aw-leer", !gewaehlt);
huelle.querySelector(".aw-knopf").disabled = select.disabled;
}
/** Ein <select> um seinen eigenen Knopf ergaenzen. */
function auswahlAufsetzen(select) {
if (select.dataset.aw || select.multiple || select.size > 1
|| select.dataset.auswahl === "aus") {
return;
}
select.dataset.aw = "1";
const huelle = document.createElement("span");
huelle.className = "aw";
const knopf = document.createElement("button");
knopf.type = "button";
// Die Klassen des <select> wandern mit: daran haengt, wie das Feld an
// dieser Stelle aussehen soll (.feld-als-text im Editor, .kw-format in den
// Einstellungen). Nur Bootstraps eigene Feldgestaltung bleibt weg - die
// bringt ihren eigenen hellen Pfeil mit.
const klassen = [...select.classList].filter(c => !c.startsWith("form-select") && !c.startsWith("form-control"));
knopf.className = ["aw-knopf"].concat(klassen).join(" ");
if (klassen.indexOf("feld-als-text") >= 0) huelle.classList.add("aw-text");
knopf.setAttribute("aria-haspopup", "listbox");
knopf.setAttribute("aria-expanded", "false");
const zettel = select.id ? document.querySelector("label[for='" + CSS.escape(select.id) + "']") : null;
const beschriftung = select.getAttribute("aria-label") || (zettel ? zettel.textContent.trim() : "");
if (beschriftung) knopf.setAttribute("aria-label", beschriftung);
knopf.innerHTML = "<span class='aw-wert'></span><i class='bi bi-chevron-down'></i>";
select.parentNode.insertBefore(huelle, select);
huelle.appendChild(knopf);
huelle.appendChild(select);
auswahlAktualisieren(select);
select.addEventListener("change", () => auswahlAktualisieren(select));
knopf.addEventListener("click", () => {
if (auswahlOffen && auswahlOffen.select === select) auswahlSchliessen();
else auswahlOeffnen(select, knopf);
});
knopf.addEventListener("keydown", e => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
auswahlOeffnen(select, knopf);
}
});
}
/** Alle noch nicht ergaenzten <select> unterhalb von wurzel uebernehmen. */
function auswahlAlle(wurzel) {
(wurzel || document).querySelectorAll("select:not([data-aw])").forEach(auswahlAufsetzen);
}
function auswahlSchliessen(zurueck) {
if (!auswahlOffen) return;
const knopf = auswahlOffen.knopf;
auswahlOffen.panel.remove();
knopf.setAttribute("aria-expanded", "false");
auswahlOffen = null;
if (zurueck && document.body.contains(knopf)) knopf.focus();
}
function auswahlOeffnen(select, knopf) {
auswahlSchliessen();
// Steckt der Knopf in einem ausgeblendeten Reiter, hat er keine Lage - die
// Liste landete sonst in der Ecke oben links.
if (!knopf.getBoundingClientRect().width) return;
const panel = document.createElement("div");
panel.className = "aw-panel";
if (select.options.length >= AUSWAHL_SUCHE_AB) {
panel.innerHTML = "<input type='search' class='aw-suche' placeholder='Suchen …' aria-label='In der Liste suchen'>";
}
const liste = document.createElement("div");
liste.className = "aw-liste";
liste.setAttribute("role", "listbox");
panel.appendChild(liste);
// Gruppen bleiben Gruppen: in den Einstellungen stehen die Geraete nach
// Etagen sortiert, und ohne die Zwischenueberschrift waere die Liste eine
// Wand aus Namen.
const eintraege = [];
const bauen = function (opt) {
const k = document.createElement("button");
k.type = "button";
k.className = "aw-eintrag";
k.setAttribute("role", "option");
k.setAttribute("aria-selected", String(opt.selected));
k.disabled = opt.disabled;
k.textContent = opt.textContent;
k.addEventListener("click", () => auswahlWaehlen(opt));
liste.appendChild(k);
eintraege.push({ knopf: k, option: opt });
};
[...select.children].forEach(kind => {
if (kind.tagName === "OPTGROUP") {
const titel = document.createElement("div");
titel.className = "aw-gruppe";
titel.textContent = kind.label;
liste.appendChild(titel);
[...kind.children].forEach(bauen);
} else if (kind.tagName === "OPTION") {
bauen(kind);
}
});
document.body.appendChild(panel);
auswahlOffen = { select: select, knopf: knopf, panel: panel, eintraege: eintraege };
knopf.setAttribute("aria-expanded", "true");
auswahlStellen();
const gewaehlt = eintraege.findIndex(e => e.option.selected);
if (gewaehlt >= 0) {
eintraege[gewaehlt].knopf.classList.add("aktiv");
eintraege[gewaehlt].knopf.scrollIntoView({ block: "center" });
}
const suche = panel.querySelector(".aw-suche");
if (suche) {
suche.focus({ preventScroll: true });
suche.addEventListener("input", () => {
const text = suche.value.trim().toLowerCase();
eintraege.forEach(e => { e.knopf.hidden = text !== "" && e.knopf.textContent.toLowerCase().indexOf(text) < 0; });
// Ein Gruppentitel ohne sichtbare Eintraege darunter verschwindet mit.
[...liste.querySelectorAll(".aw-gruppe")].forEach(titel => {
let el = titel.nextElementSibling, sichtbar = false;
while (el && !el.classList.contains("aw-gruppe")) {
if (!el.hidden) { sichtbar = true; break; }
el = el.nextElementSibling;
}
titel.hidden = !sichtbar;
});
auswahlStellen();
});
}
}
/** Die Liste an ihren Platz legen: unter den Knopf, sonst darueber. */
function auswahlStellen() {
if (!auswahlOffen) return;
const knopf = auswahlOffen.knopf, panel = auswahlOffen.panel;
const r = knopf.getBoundingClientRect();
const breite = Math.min(Math.max(r.width, AUSWAHL_MIN_BREITE), window.innerWidth - 16);
panel.style.width = breite + "px";
const platzUnten = window.innerHeight - r.bottom - 12;
const platzOben = r.top - 12;
const unten = platzUnten >= Math.min(AUSWAHL_MAX_HOEHE, panel.scrollHeight) || platzUnten >= platzOben;
panel.style.maxHeight = Math.min(AUSWAHL_MAX_HOEHE, unten ? platzUnten : platzOben) + "px";
panel.style.left = Math.max(8, Math.min(r.left, window.innerWidth - breite - 8)) + "px";
panel.style.top = unten ? (r.bottom + 4) + "px" : "";
panel.style.bottom = unten ? "" : (window.innerHeight - r.top + 4) + "px";
}
function auswahlWaehlen(option) {
if (!auswahlOffen) return;
const select = auswahlOffen.select;
const knopf = auswahlOffen.knopf;
auswahlSchliessen();
if (option.disabled) return;
if (select.value !== option.value) {
select.value = option.value;
auswahlAktualisieren(select);
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
}
// Nach einem Neuzeichnen - der Editor baut seine Zeilen neu - gibt es den
// Knopf vielleicht gar nicht mehr.
if (document.body.contains(knopf)) knopf.focus();
}
function auswahlSchritt(richtung) {
const sichtbar = auswahlOffen.eintraege.filter(e => !e.knopf.hidden && !e.knopf.disabled);
if (!sichtbar.length) return;
const jetzt = sichtbar.findIndex(e => e.knopf.classList.contains("aktiv"));
const naechst = jetzt < 0 ? (richtung > 0 ? 0 : sichtbar.length - 1)
: (jetzt + richtung + sichtbar.length) % sichtbar.length;
auswahlOffen.eintraege.forEach(e => e.knopf.classList.remove("aktiv"));
sichtbar[naechst].knopf.classList.add("aktiv");
sichtbar[naechst].knopf.scrollIntoView({ block: "nearest" });
}
document.addEventListener("pointerdown", e => {
if (!auswahlOffen) return;
if (e.target.closest(".aw-panel") || e.target.closest(".aw")) return;
auswahlSchliessen();
}, true);
document.addEventListener("keydown", e => {
if (!auswahlOffen) return;
if (e.key === "Escape") {
// Ohne das Anhalten macht Bootstrap das Modal darunter gleich mit zu.
e.preventDefault();
e.stopPropagation();
auswahlSchliessen(true);
} else if (e.key === "ArrowDown") {
e.preventDefault();
auswahlSchritt(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
auswahlSchritt(-1);
} else if (e.key === "Enter") {
const aktiv = auswahlOffen.eintraege.find(x => x.knopf.classList.contains("aktiv") && !x.knopf.hidden);
if (aktiv) {
e.preventDefault();
auswahlWaehlen(aktiv.option);
}
} else if (e.key === "Tab") {
auswahlSchliessen();
}
}, true);
// Scrollt die Seite oder das Modal, wandert die Liste mit; ist der Knopf dabei
// aus dem Bild, geht sie zu.
window.addEventListener("scroll", () => {
if (!auswahlOffen) return;
const r = auswahlOffen.knopf.getBoundingClientRect();
if (r.bottom < 0 || r.top > window.innerHeight) auswahlSchliessen();
else auswahlStellen();
}, true);
window.addEventListener("resize", () => auswahlSchliessen());
document.addEventListener("DOMContentLoaded", function () {
auswahlAlle(document);
// Was spaeter dazukommt - Modals, neu gezeichnete Editorzeilen - wird von
// selbst uebernommen; die Aufrufer muessen daran nicht denken.
new MutationObserver(aenderungen => {
for (const a of aenderungen) {
for (const knoten of a.addedNodes) {
if (knoten.nodeType !== 1) continue;
if (knoten.tagName === "SELECT") auswahlAufsetzen(knoten);
else auswahlAlle(knoten);
}
}
}).observe(document.body, { childList: true, subtree: true });
});