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>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* 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 });
|
||||
});
|
||||
+20
-41
@@ -620,60 +620,39 @@ function rfLampeStarten(karte) {
|
||||
eigen.addEventListener("change", () => setzen(eigen.value));
|
||||
}
|
||||
|
||||
// Preset, Effekt und andere Listen: Chips, lange Listen im aufklappbaren
|
||||
// Picker mit Suche.
|
||||
// Preset, Effekt und andere Listen: wenige Eintraege als Chips, lange
|
||||
// Listen als <select>, aus dem js/solar/auswahl.js die eigene Liste macht.
|
||||
karte.querySelectorAll(".rf-liste").forEach(liste => {
|
||||
const knopf = liste.querySelector(".rf-picker-knopf");
|
||||
const picker = liste.querySelector(".rf-picker");
|
||||
const suche = liste.querySelector(".rf-suche");
|
||||
const auf = offen => {
|
||||
if (!picker) return;
|
||||
picker.hidden = !offen;
|
||||
knopf.setAttribute("aria-expanded", String(offen));
|
||||
if (offen) {
|
||||
suche.value = "";
|
||||
liste.querySelectorAll("[data-wert]").forEach(k => { k.hidden = false; });
|
||||
const gewaehlt = liste.querySelector("[data-wert][aria-pressed='true']");
|
||||
if (gewaehlt) gewaehlt.scrollIntoView({ block: "nearest" });
|
||||
suche.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
if (knopf) knopf.addEventListener("click", () => auf(picker.hidden));
|
||||
if (suche) {
|
||||
suche.addEventListener("input", () => {
|
||||
const text = suche.value.trim().toLowerCase();
|
||||
liste.querySelectorAll("[data-wert]").forEach(k => { k.hidden = text !== "" && !k.textContent.toLowerCase().includes(text); });
|
||||
});
|
||||
suche.addEventListener("keydown", e => {
|
||||
if (e.key === "Escape") { e.stopPropagation(); auf(false); knopf.focus(); }
|
||||
if (e.key === "Enter") {
|
||||
const erster = [...liste.querySelectorAll("[data-wert]")].find(k => !k.hidden);
|
||||
if (erster) erster.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
liste.querySelectorAll("[data-wert]").forEach(k => k.addEventListener("click", () => {
|
||||
rfListeSetzen(liste, k.dataset.wert);
|
||||
auf(false);
|
||||
const params = {};
|
||||
params[liste.dataset.param] = k.dataset.wert;
|
||||
rfKommando(Number(liste.dataset.cmd), params, liste);
|
||||
rfListeSenden(liste, k.dataset.wert);
|
||||
}));
|
||||
const auswahl = liste.querySelector("select");
|
||||
if (auswahl) auswahl.addEventListener("change", () => rfListeSenden(liste, auswahl.value));
|
||||
});
|
||||
|
||||
if (karte.dataset.wled) rfWledLesen(karte);
|
||||
}
|
||||
|
||||
/** Den gewaehlten Eintrag an das Geraet schicken. */
|
||||
function rfListeSenden(liste, wert) {
|
||||
const params = {};
|
||||
params[liste.dataset.param] = wert;
|
||||
rfKommando(Number(liste.dataset.cmd), params, liste);
|
||||
}
|
||||
|
||||
/** Einen Eintrag einer Liste als gewaehlt zeigen; null = keiner. */
|
||||
function rfListeSetzen(liste, wert) {
|
||||
let name = null;
|
||||
liste.querySelectorAll("[data-wert]").forEach(k => {
|
||||
const ja = wert !== null && k.dataset.wert === String(wert);
|
||||
k.setAttribute("aria-pressed", String(ja));
|
||||
if (ja) name = k.textContent;
|
||||
k.setAttribute("aria-pressed", String(wert !== null && k.dataset.wert === String(wert)));
|
||||
});
|
||||
const anzeige = liste.querySelector("[data-rf-picker-wert]");
|
||||
if (anzeige) anzeige.textContent = name === null ? "–" : name;
|
||||
const auswahl = liste.querySelector("select");
|
||||
if (auswahl && wert !== null) {
|
||||
auswahl.value = String(wert);
|
||||
// Die Beschriftung des eigenen Knopfes haengt am <select>, nicht am DOM
|
||||
// darunter - sie muss also mitgezogen werden.
|
||||
if (typeof auswahlAktualisieren === "function") auswahlAktualisieren(auswahl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user