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 . "";