From d36989ae0d25d387032e00c66f14abedf9ed35bd Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Wed, 16 Sep 2026 07:55:17 +0200 Subject: [PATCH] Eigene Auswahllisten ueberall: dunkel und ueber der Seite statt im Fluss Chrome zeichnet die Liste eines um einen eigenen Knopf; die Liste haengt an 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 --- README.md | 12 ++ css/solar.css | 97 ++++++++++--- js/solar/auswahl.js | 269 ++++++++++++++++++++++++++++++++++++ js/solar/raumfenster.js | 61 +++----- restricted/footer.php | 1 + restricted/roomControls.php | 20 +-- 6 files changed, 391 insertions(+), 69 deletions(-) create mode 100644 js/solar/auswahl.js diff --git a/README.md b/README.md index 27f94c8..9cc32f2 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,17 @@ Lampen mit Birne als Schalter, Farbfeldern und Effekten. Jede Wahl gilt sofort. Jalousien werden nach einem Befehl bis zum Stillstand verfolgt (`rfJalousieVerfolgen()`), die Heizwerte kommen über `raumfensterMqtt()` live. +**`auswahl.js`** — eine Auswahlliste für die ganze Anwendung. Wird auf jeder +Seite geladen (`footer.php`) und ergänzt jedes `` bleibt stehen und hält weiter den Wert, vorhandene +`onchange`-Zuhörer merken nichts davon. Die aufgeklappte Liste hängt an +`` und liegt mit `position: fixed` über der Seite — im Fluss machte sie +ein Modal höher, und der ganze Inhalt sprang. Ab neun Einträgen gibt es ein +Suchfeld, Gruppen (``) bleiben Gruppen, die Tastatur bedient sie. +Neue `` schreiben — `js/solar/auswahl.js` macht daraus die eigene Liste. Keine zweite Variante bauen | | Neues Symbol auf einer Kachel | Name eines Bootstrap-Icons ins Feld tippen. Vorschlagsliste: `kachelSymbole()` in `kacheln.php` | | Symbol für eine Gerätegruppe (Raum-Modal, Zeitleiste, Editor) | ein Bootstrap-Icon in `raumGruppenTitel()` (`roomControls.php`), `zeitleisteBahnen()` (`zeitleiste.php`) und `GERAETE_SYMBOL` (`autoActionFuncs.js`) — überall dasselbe. Fehlt es in der Icon-Schrift, eine eigene Maske in `css/solar.css` anlegen (Vorbild: `bi-beschattung`) | | Neue Kennzahl in der Jahresstatistik | `KENNZAHLEN` **und** `kennzahlenAus()` in `ajax/getStats.php` — sonst nichts | diff --git a/css/solar.css b/css/solar.css index 7a10dcb..5d39d6e 100644 --- a/css/solar.css +++ b/css/solar.css @@ -111,6 +111,78 @@ body.bg-body-tertiary, mask: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22%3E%3Crect x=%221.5%22 y=%221.6%22 width=%2213%22 height=%222.6%22 rx=%22.9%22/%3E%3Crect x=%222.6%22 y=%225.7%22 width=%2210.8%22 height=%221.7%22 rx=%22.85%22/%3E%3Crect x=%222.6%22 y=%228.6%22 width=%2210.8%22 height=%221.7%22 rx=%22.85%22/%3E%3Crect x=%221.5%22 y=%2211.8%22 width=%2213%22 height=%222.6%22 rx=%22.9%22/%3E%3C/svg%3E") center / contain no-repeat; } +/* --- Auswahllisten ------------------------------------------------------- + Eigener Knopf statt der Liste, die der Browser zeichnet (js/solar/auswahl.js). + Chrome malt sie hell auf grau, und gestalten laesst sie sich nicht. Die + aufgeklappte Liste haengt an und liegt ueber der Seite - im Fluss + machte sie ein Modal hoeher, und der ganze Inhalt sprang. + -------------------------------------------------------------------------- */ +.aw { display: block; position: relative; min-width: 0; } +.aw.aw-text { display: inline-block; max-width: 100%; } +.aw > select { display: none; } +.aw .aw-knopf { + display: flex; align-items: center; justify-content: space-between; gap: .4rem; + width: 100%; min-width: 0; padding: .35rem .6rem; + border: 1px solid var(--ton-linie-satt, rgba(138, 148, 158, .38)); + border-radius: var(--ton-radius-klein, .5rem); + background: var(--ton-flaeche-2, #2b3237); color: var(--ton-schrift, #e8ede9); + font: inherit; text-align: left; cursor: pointer; +} +.aw .aw-knopf:hover { border-color: var(--bs-link-color, #f0b429); } +.aw .aw-knopf:focus-visible { outline: 2px solid var(--bs-link-color, #f0b429); outline-offset: 1px; } +.aw .aw-knopf[aria-expanded="true"] { border-color: var(--bs-link-color, #f0b429); } +.aw .aw-knopf:disabled { opacity: .55; cursor: default; } +.aw .aw-wert { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.aw .aw-wert.aw-leer { color: var(--ton-schrift-3, #6f7a81); } +.aw .aw-knopf > .bi { flex: none; font-size: .7em; color: var(--ton-schrift-2, #9aa6ad); transition: transform .2s; } +.aw .aw-knopf[aria-expanded="true"] > .bi { transform: rotate(180deg); } +/* Als Text im Fliesstext (Automatik-Editor): der Knopf traegt dort die + Gestaltung von .feld-als-text, nur der Pfeil kommt dazu. */ +.aw .aw-knopf.feld-als-text { display: inline-flex; width: auto; max-width: 100%; } +.input-group > .aw { flex: 1 1 auto; width: 1%; } +.input-group > .aw .aw-knopf { border-radius: 0; height: 100%; } +.input-group > .aw:last-child .aw-knopf { border-top-right-radius: var(--ton-radius-klein, .5rem); border-bottom-right-radius: var(--ton-radius-klein, .5rem); } + +/* Ein aufmacht, zeichnet er selbst - + * bei Chrome hell auf grau, mitten in einer dunklen Seite. Gestalten laesst + * sie sich nicht. Deshalb bekommt hier jedes 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 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 , 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 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 = ""; + } + 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 }); +}); diff --git a/js/solar/raumfenster.js b/js/solar/raumfenster.js index 6370ea8..1e70daa 100644 --- a/js/solar/raumfenster.js +++ b/js/solar/raumfenster.js @@ -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 , nicht am DOM + // darunter - sie muss also mitgezogen werden. + if (typeof auswahlAktualisieren === "function") auswahlAktualisieren(auswahl); + } } /** diff --git a/restricted/footer.php b/restricted/footer.php index 1eb97d2..ace112c 100644 --- a/restricted/footer.php +++ b/restricted/footer.php @@ -70,6 +70,7 @@ + diff --git a/restricted/roomControls.php b/restricted/roomControls.php index 13568c6..0d1e888 100644 --- a/restricted/roomControls.php +++ b/restricted/roomControls.php @@ -508,8 +508,7 @@ function zeichneLicht($d) // Presets, Effekte und andere Listen. Wenige Eintraege stehen als Chips // da; lange Listen (WLED hat ueber 180 Effekte) bekommen einen Knopf mit // dem aktuellen Eintrag, der eine durchsuchbare Liste aufklappt. Die - // native Auswahlliste zeichnet der Browser hell auf hell und passt nicht - // in das dunkle Fenster. Welcher Eintrag gerade gilt, liest + // Welcher Eintrag gerade gilt, liest // js/solar/raumfenster.js bei WLED live aus (room.php?action=wled); // data-feld ist der Parametername, "effect" oder "preset". foreach ($listen as $c) { @@ -525,13 +524,16 @@ function zeichneLicht($d) if (count($p["options"]) <= 16) { $html .= $kopf . "
" . $knoepfe . "
"; } else { - $html .= $kopf - . "" - . ""; + // Ueber 180 WLED-Effekte waeren als Chips eine Wand. Aus dem + // "; + foreach ($p["options"] as $o) { + $html .= ""; + } + $html .= ""; } } return $html . "";