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:
2026-09-16 07:55:17 +02:00
co-authored by Claude Opus 5
parent fe8630eb4f
commit d36989ae0d
6 changed files with 391 additions and 69 deletions
+12
View File
@@ -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 sofort. Jalousien werden nach einem Befehl bis zum Stillstand verfolgt
(`rfJalousieVerfolgen()`), die Heizwerte kommen über `raumfensterMqtt()` live. (`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 `<select>` um einen eigenen
Knopf; das `<select>` bleibt stehen und hält weiter den Wert, vorhandene
`onchange`-Zuhörer merken nichts davon. Die aufgeklappte Liste hängt an
`<body>` 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 (`<optgroup>`) bleiben Gruppen, die Tastatur bedient sie.
Neue `<select>` übernimmt ein `MutationObserver` von selbst. Wer den Wert im
Code setzt, ruft `auswahlAktualisieren(select)` oder löst `change` aus;
`data-auswahl="aus"` lässt ein Feld in Ruhe.
**`solarMQTT.js`** — die Solar-Seite. Startet die Energiefluss-Übersicht **`solarMQTT.js`** — die Solar-Seite. Startet die Energiefluss-Übersicht
(`energieflussStarten()`) samt Anlage-Ansicht, füllt die Bewässerungszonen, (`energieflussStarten()`) samt Anlage-Ansicht, füllt die Bewässerungszonen,
darunter hängen die Diagramme Prognose, Verbrauch und Erzeugung über darunter hängen die Diagramme Prognose, Verbrauch und Erzeugung über
@@ -887,6 +898,7 @@ ausgeliefert.
| Ein anderes Haus einrichten | `homeMesh_DB-layout.sql`, `homeMesh_automations.sql`, `homeMesh_grundriss.sql`, `homeMesh_energiefluss.sql` in dieser Reihenfolge; dann Einstellungen → Grundriss und → Übersicht | | Ein anderes Haus einrichten | `homeMesh_DB-layout.sql`, `homeMesh_automations.sql`, `homeMesh_grundriss.sql`, `homeMesh_energiefluss.sql` in dieser Reihenfolge; dann Einstellungen → Grundriss und → Übersicht |
| Kreis der Energiefluss-Übersicht aus, umbenennen, verschieben; PV-Flächen | Einstellungen → Übersicht (keine Codeänderung) | | Kreis der Energiefluss-Übersicht aus, umbenennen, verschieben; PV-Flächen | Einstellungen → Übersicht (keine Codeänderung) |
| Neuer Kreis in der Energiefluss-Übersicht (Gerät, Etage, Quelle) | ein Eintrag in `js/solar/energieflussKatalog.js`; neues Symbol dazu in `SYMBOLE` (`energiefluss.js`) | | Neuer Kreis in der Energiefluss-Übersicht (Gerät, Etage, Quelle) | ein Eintrag in `js/solar/energieflussKatalog.js`; neues Symbol dazu in `SYMBOLE` (`energiefluss.js`) |
| Auswahlliste irgendwo | ein gewöhnliches `<select>` 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` | | 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`) | | 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 | | Neue Kennzahl in der Jahresstatistik | `KENNZAHLEN` **und** `kennzahlenAus()` in `ajax/getStats.php` — sonst nichts |
+78 -19
View File
@@ -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; 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 <body> 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 <select> mit schwebender Beschriftung (.form-floating): Bootstrap
laesst die Beschriftung nur dann nach oben ruecken, wenn es das Feld
selbst kennt. Unser Knopf ist keins - also steht sie hier fest oben, was
bei einer Auswahl ohnehin immer gilt: sie hat immer einen Wert. */
.form-floating > .aw .aw-knopf { height: calc(3.5rem + 2px); padding: 1.625rem .75rem .625rem; }
.form-floating > .aw ~ label { opacity: .65; transform: scale(.85) translateY(-.5rem) translateX(.15rem); }
.aw-panel {
position: fixed; z-index: 2000; display: flex; flex-direction: column; gap: .35rem;
padding: .35rem; border-radius: var(--ton-radius-klein, .5rem);
border: 1px solid var(--ton-linie-satt, rgba(138, 148, 158, .38));
background: var(--ton-flaeche, #23282c); box-shadow: 0 .8rem 2rem rgba(0, 0, 0, .5);
animation: aw-auf .15s cubic-bezier(.2, .7, .2, 1);
}
@keyframes aw-auf { from { opacity: 0; transform: translateY(-4px); } }
.aw-panel .aw-suche {
flex: none; padding: .35rem .55rem; border: 0; border-radius: .4rem;
background: var(--ton-flaeche-2, #2b3237); color: var(--ton-schrift, #e8ede9); font: inherit;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .08);
}
.aw-panel .aw-suche::placeholder { color: var(--ton-schrift-3, #6f7a81); }
.aw-panel .aw-suche:focus { outline: 0; box-shadow: inset 0 0 0 1.5px var(--bs-link-color, #f0b429); }
.aw-panel .aw-liste { display: block; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #3f474e transparent; }
.aw-panel .aw-gruppe {
padding: .4rem .55rem .15rem; font-size: .7rem; font-weight: 600; letter-spacing: .06em;
text-transform: uppercase; color: var(--ton-schrift-3, #6f7a81);
}
.aw-panel .aw-gruppe[hidden] { display: none; }
.aw-panel .aw-eintrag {
display: block; box-sizing: border-box; width: 100%; margin: 0; cursor: pointer;
padding: .35rem .55rem; border: 0; border-radius: .4rem; background: none; appearance: none;
font: inherit; font-size: .9rem; line-height: 1.5; text-align: left;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: var(--ton-schrift, #e8ede9);
}
.aw-panel .aw-eintrag[hidden] { display: none; }
.aw-panel .aw-eintrag:hover, .aw-panel .aw-eintrag.aktiv { background: var(--ton-flaeche-2, #2b3237); }
.aw-panel .aw-eintrag[aria-selected="true"] { color: var(--bs-link-color, #f0b429); font-weight: 600; }
.aw-panel .aw-eintrag:disabled { opacity: .45; cursor: default; }
/* --- Karten -------------------------------------------------------------- /* --- Karten --------------------------------------------------------------
Eine Karte ist eine Flaeche, kein Kasten: ein Ton heller als der Grund, eine Eine Karte ist eine Flaeche, kein Kasten: ein Ton heller als der Grund, eine
Haarlinie als Rand, kein Schlagschatten. Der Schatten trennte frueher, was Haarlinie als Rand, kein Schlagschatten. Der Schatten trennte frueher, was
@@ -1131,30 +1203,17 @@ input[type='range']::-webkit-slider-runnable-track {
.rf .rf-farbe:focus-visible, .rf .rf-farbe-eigen:focus-within { outline: 2px solid #f7c000; outline-offset: 3px; } .rf .rf-farbe:focus-visible, .rf .rf-farbe-eigen:focus-within { outline: 2px solid #f7c000; outline-offset: 3px; }
.rf .rf-farbe-eigen { background: conic-gradient(#f55, #fd5, #5f7, #5df, #75f, #f5d, #f55); } .rf .rf-farbe-eigen { background: conic-gradient(#f55, #fd5, #5f7, #5df, #75f, #f5d, #f55); }
.rf .rf-farbe-eigen input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; } .rf .rf-farbe-eigen input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
/* Preset, Effekt: Titel links, Chips oder Picker rechts. Lange Listen /* Preset und Effekt: Titel links, Chips oder die allgemeine Auswahl rechts
klappen als durchsuchbarer Kasten unter der Zeile auf. */ (js/solar/auswahl.js) - lange Listen waeren als Chips eine Wand. */
.rf .rf-liste { display: grid; grid-template-columns: 4.3rem minmax(0, 1fr); align-items: start; gap: .4rem .5rem; padding-top: .55rem; border-top: 1px solid rgba(255, 255, 255, .06); } .rf .rf-liste { display: grid; grid-template-columns: 4.3rem minmax(0, 1fr); align-items: center; gap: .4rem .5rem; padding-top: .55rem; border-top: 1px solid rgba(255, 255, 255, .06); }
.rf .rf-liste + .rf-liste { border-top: 0; padding-top: 0; } .rf .rf-liste + .rf-liste { border-top: 0; padding-top: 0; }
.rf .rf-liste > .rf-reihe-titel { padding-top: .35rem; }
.rf .rf-liste .rf-chip { padding: .28rem .65rem; font-size: .78rem; } .rf .rf-liste .rf-chip { padding: .28rem .65rem; font-size: .78rem; }
.rf .rf-liste .rf-chip[aria-pressed="true"] { color: #1a1406; } .rf .rf-liste .rf-chip[aria-pressed="true"] { color: #1a1406; }
.rf .rf-picker-knopf { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: .5rem; min-width: 0; padding: .38rem .7rem; border-radius: .6rem; background: #22292d; font-size: .82rem; font-weight: 500; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06); } .rf .rf-liste .aw .aw-knopf { background: #22292d; border-color: transparent; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06); font-size: .82rem; font-weight: 500; }
.rf .rf-picker-knopf span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .rf .rf-liste .aw .aw-knopf:hover { background: #283035; }
.rf .rf-picker-knopf .bi { font-size: .75rem; color: var(--ton-schrift-2, #9aa6ad); transition: transform .2s; } .rf .rf-liste .aw .aw-knopf[aria-expanded="true"] { border-color: transparent; box-shadow: inset 0 0 0 1.5px var(--a); }
.rf .rf-picker-knopf:hover { background: #283035; }
.rf .rf-picker-knopf[aria-expanded="true"] { box-shadow: inset 0 0 0 1.5px var(--a); }
.rf .rf-picker-knopf[aria-expanded="true"] .bi { transform: rotate(180deg); }
.rf .rf-picker-knopf:focus-visible { outline: 2px solid var(--a); outline-offset: 1px; }
.rf .rf-picker { grid-column: 1 / -1; display: grid; gap: .45rem; padding: .5rem; border-radius: .65rem; background: #22292d; animation: rf-auf .2s cubic-bezier(.2, .7, .2, 1); }
.rf .rf-picker[hidden] { display: none; }
.rf .rf-suche { all: unset; box-sizing: border-box; width: 100%; padding: .4rem .65rem; border-radius: .5rem; background: var(--ton-flaeche-2, #2b3237); color: var(--ton-schrift, #e8ede9); font-size: .82rem; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .08); }
.rf .rf-suche::placeholder { color: var(--ton-schrift-3, #6f7a81); }
.rf .rf-suche:focus { box-shadow: inset 0 0 0 1.5px var(--a); }
.rf .rf-picker-liste { display: flex; flex-wrap: wrap; align-content: flex-start; gap: .3rem; max-height: 11rem; overflow-y: auto; padding-right: .25rem; scrollbar-width: thin; scrollbar-color: #3f474e transparent; }
.rf .rf-picker-liste .rf-chip[hidden] { display: none; }
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.rf .rf-liste { grid-template-columns: 1fr; gap: .3rem; } .rf .rf-liste { grid-template-columns: 1fr; gap: .3rem; }
.rf .rf-liste > .rf-reihe-titel { padding-top: 0; }
} }
/* --- Schalter --- */ /* --- Schalter --- */
+269
View File
@@ -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
View File
@@ -620,60 +620,39 @@ function rfLampeStarten(karte) {
eigen.addEventListener("change", () => setzen(eigen.value)); eigen.addEventListener("change", () => setzen(eigen.value));
} }
// Preset, Effekt und andere Listen: Chips, lange Listen im aufklappbaren // Preset, Effekt und andere Listen: wenige Eintraege als Chips, lange
// Picker mit Suche. // Listen als <select>, aus dem js/solar/auswahl.js die eigene Liste macht.
karte.querySelectorAll(".rf-liste").forEach(liste => { 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", () => { liste.querySelectorAll("[data-wert]").forEach(k => k.addEventListener("click", () => {
rfListeSetzen(liste, k.dataset.wert); rfListeSetzen(liste, k.dataset.wert);
auf(false); rfListeSenden(liste, k.dataset.wert);
const params = {};
params[liste.dataset.param] = k.dataset.wert;
rfKommando(Number(liste.dataset.cmd), params, liste);
})); }));
const auswahl = liste.querySelector("select");
if (auswahl) auswahl.addEventListener("change", () => rfListeSenden(liste, auswahl.value));
}); });
if (karte.dataset.wled) rfWledLesen(karte); 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. */ /** Einen Eintrag einer Liste als gewaehlt zeigen; null = keiner. */
function rfListeSetzen(liste, wert) { function rfListeSetzen(liste, wert) {
let name = null;
liste.querySelectorAll("[data-wert]").forEach(k => { liste.querySelectorAll("[data-wert]").forEach(k => {
const ja = wert !== null && k.dataset.wert === String(wert); k.setAttribute("aria-pressed", String(wert !== null && k.dataset.wert === String(wert)));
k.setAttribute("aria-pressed", String(ja));
if (ja) name = k.textContent;
}); });
const anzeige = liste.querySelector("[data-rf-picker-wert]"); const auswahl = liste.querySelector("select");
if (anzeige) anzeige.textContent = name === null ? "" : name; 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);
}
} }
/** /**
+1
View File
@@ -70,6 +70,7 @@
<script src="js/leaflet.js"></script> <script src="js/leaflet.js"></script>
<?php endif; ?> <?php endif; ?>
<script src="js/solar/common.js?v=<?= filemtime("js/solar/common.js") ?>"></script> <script src="js/solar/common.js?v=<?= filemtime("js/solar/common.js") ?>"></script>
<script src="js/solar/auswahl.js?v=<?= filemtime("js/solar/auswahl.js") ?>"></script>
<?php foreach ($page["scripts"] as $script): ?> <?php foreach ($page["scripts"] as $script): ?>
<script src="<?= $script ?>?v=<?= filemtime($script) ?>"></script> <script src="<?= $script ?>?v=<?= filemtime($script) ?>"></script>
<?php endforeach; ?> <?php endforeach; ?>
+11 -9
View File
@@ -508,8 +508,7 @@ function zeichneLicht($d)
// Presets, Effekte und andere Listen. Wenige Eintraege stehen als Chips // Presets, Effekte und andere Listen. Wenige Eintraege stehen als Chips
// da; lange Listen (WLED hat ueber 180 Effekte) bekommen einen Knopf mit // da; lange Listen (WLED hat ueber 180 Effekte) bekommen einen Knopf mit
// dem aktuellen Eintrag, der eine durchsuchbare Liste aufklappt. Die // dem aktuellen Eintrag, der eine durchsuchbare Liste aufklappt. Die
// native Auswahlliste zeichnet der Browser hell auf hell und passt nicht // Welcher Eintrag gerade gilt, liest
// in das dunkle Fenster. Welcher Eintrag gerade gilt, liest
// js/solar/raumfenster.js bei WLED live aus (room.php?action=wled); // js/solar/raumfenster.js bei WLED live aus (room.php?action=wled);
// data-feld ist der Parametername, "effect" oder "preset". // data-feld ist der Parametername, "effect" oder "preset".
foreach ($listen as $c) { foreach ($listen as $c) {
@@ -525,13 +524,16 @@ function zeichneLicht($d)
if (count($p["options"]) <= 16) { if (count($p["options"]) <= 16) {
$html .= $kopf . "<div class='rf-chips'>" . $knoepfe . "</div></div>"; $html .= $kopf . "<div class='rf-chips'>" . $knoepfe . "</div></div>";
} else { } else {
$html .= $kopf // Ueber 180 WLED-Effekte waeren als Chips eine Wand. Aus dem
. "<button type='button' class='rf-picker-knopf' aria-expanded='false'>" // <select> macht js/solar/auswahl.js die eigene Liste mit Suchfeld,
. "<span data-rf-picker-wert></span><i class='bi bi-chevron-down'></i></button>" // und die liegt ueber dem Fenster, statt es hoeher zu machen.
. "<div class='rf-picker' hidden>" $html .= $kopf . "<select class='form-select form-select-sm'"
. "<input type='search' class='rf-suche' placeholder='" . htmlspecialchars($c["name"], ENT_QUOTES) . " suchen …'" . " aria-label='" . htmlspecialchars($c["name"], ENT_QUOTES) . "'>";
. " aria-label='" . htmlspecialchars($c["name"], ENT_QUOTES) . " suchen'>" foreach ($p["options"] as $o) {
. "<div class='rf-picker-liste'>" . $knoepfe . "</div></div></div>"; $html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
. htmlspecialchars($o["label"]) . "</option>";
}
$html .= "</select></div>";
} }
} }
return $html . "</div></div>"; return $html . "</div></div>";