Files
dogfather-universe/workspace/assets/js/wahl.js
T
DogFatherGit 7c81b17f9c Workspace: alle Auswahlfelder ohne Systemmenue
Filipe: "die kisten wo die namen drin stehen um auszuwaehlen -- kannst
du doch ueberall besser aussehen lassen". Betraf nicht eine Stelle,
sondern 28 Auswahlfelder auf zehn Seiten.

Deshalb EIN Baustein (assets/js/wahl.js) statt zehn Einzelloesungen.

WIE ER ARBEITET:
Das echte <select> bleibt im Dokument und behaelt seinen Wert -- es wird
nur unsichtbar. Darueber liegt ein eigener Knopf mit eigener Liste.
Damit funktioniert alles weiter, was schon da war: jedes `feld.value`,
jedes `change`-Ereignis, jedes Formular. Ich musste keine einzige der
zehn Seiten in ihrer Logik anfassen.

Faellt das Skript aus, ist wieder das gewohnte Systemmenue da.

DREI ENTSCHEIDUNGEN, DIE WICHTIG WAREN:

1. Nicht display:none fuer das echte Feld, sondern ein Pixel und
   durchsichtig. Ein per display:none verstecktes Feld faellt aus der
   Formularpruefung des Browsers heraus -- `required` wuerde stumm nicht
   mehr greifen.

2. Die Liste haengt an position:fixed, nicht am Elternelement. In einer
   Karte mit overflow:hidden waere sie sonst abgeschnitten. Passt sie
   nach unten nicht mehr hin, klappt sie nach oben.

3. Ein MutationObserver erfasst Felder, die erst spaeter entstehen, und
   Eintraege, die nachgeladen werden. Neu gezeichnet wird im naechsten
   Bild -- so wird ein direkt nach dem Fuellen gesetztes `feld.value`
   noch mitgenommen. Geprueft an der Scout-Pipeline: Prioritaet und
   Stufe entstehen erst beim Aufklappen, beide werden aufgewertet, der
   gewaehlte Wert kommt beim Speichern richtig an.

Tastatur wie gewohnt: Pfeile oeffnen und blaettern, Buchstaben springen
zum passenden Eintrag, Esc schliesst, Klick daneben auch. Gruppen
(optgroup) werden als Zwischenueberschrift dargestellt.

Geprueft ueber zehn Seiten: 28 Felder, KEINES mehr als Systemmenue,
Werte kommen an, Seite reagiert auf die Auswahl, Handy ohne Ueberlauf,
0 Konsolenfehler.
2026-08-31 12:23:49 +02:00

206 lines
8.2 KiB
JavaScript

/* ===================================================================
Auswahlfelder, die man gestalten kann.
WARUM ES DAS GIBT:
Ein aufgeklapptes <select> wird vom Betriebssystem gezeichnet, nicht
von der Seite. In Windows kommt es weiss mit blauer Markierung --
mitten in einer dunklen Oberflaeche, und kein CSS der Welt aendert
daran etwas. Genau das hat gestoert.
WIE ES ARBEITET:
Das echte <select> bleibt im Dokument und behaelt seinen Wert. Es
wird nur unsichtbar gemacht; darueber liegt ein eigener Knopf mit
eigener Liste. Damit funktioniert alles weiter, was vorher schon da
war -- jedes `feld.value`, jedes `change`-Ereignis, jedes Formular.
Faellt dieses Skript aus, ist wieder das gewohnte Systemmenue da.
Es erfasst auch Felder, die erst spaeter entstehen (MutationObserver)
und Eintraege, die erst nachgeladen werden. Deshalb musste ich keine
dreizehn Seiten einzeln umbauen -- und die naechste ist automatisch
dabei.
Die Liste haengt an `position: fixed`, nicht am Elternelement: In
einer Karte mit `overflow: hidden` waere sie sonst abgeschnitten.
=================================================================== */
(() => {
'use strict';
const OFFEN = new WeakMap();
let aktiv = null; // gerade offene Liste
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
/* ---------- Ein Feld aufwerten ----------------------------------------- */
function werteAuf(feld) {
if (feld.multiple || feld.dataset.wahl === 'aus' || OFFEN.has(feld)) return;
const huelle = el('div', 'wahl2');
const knopf = el('button', 'wahl2__knopf');
knopf.type = 'button';
knopf.setAttribute('aria-haspopup', 'listbox');
knopf.setAttribute('aria-expanded', 'false');
const text = el('span', 'wahl2__text');
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
pfeil.setAttribute('viewBox', '0 0 24 24');
pfeil.setAttribute('class', 'wahl2__pfeil');
pfeil.setAttribute('aria-hidden', 'true');
pfeil.innerHTML = '<path d="m6 9.5 6 6 6-6"/>';
knopf.append(text, pfeil);
/* Beschriftung uebernehmen, damit der Knopf denselben Namen traegt
wie vorher das Feld. */
const schild = feld.id && document.querySelector(`label[for="${feld.id}"]`);
if (schild) knopf.setAttribute('aria-label', schild.textContent.trim());
else if (feld.getAttribute('aria-label')) knopf.setAttribute('aria-label', feld.getAttribute('aria-label'));
feld.parentNode.insertBefore(huelle, feld);
huelle.append(feld, knopf);
feld.classList.add('wahl2__echt');
const zustand = { feld, knopf, text, liste: null };
OFFEN.set(feld, zustand);
knopf.addEventListener('click', () => (aktiv === zustand ? schliessen() : oeffnen(zustand)));
knopf.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); oeffnen(zustand); }
});
/* Aendert die Seite den Wert selbst, muss der Knopf mitziehen. */
feld.addEventListener('change', () => zeige(zustand));
/* Eintraege werden oft erst nachgeladen. Nach jeder Aenderung neu
zeichnen -- im naechsten Bild, damit ein direkt danach gesetztes
`feld.value` noch mitgenommen wird. */
new MutationObserver(() => requestAnimationFrame(() => zeige(zustand)))
.observe(feld, { childList: true, subtree: true });
zeige(zustand);
}
function zeige(z) {
const gewaehlt = z.feld.selectedOptions[0];
z.text.textContent = gewaehlt ? gewaehlt.textContent : '';
z.knopf.dataset.leer = gewaehlt && gewaehlt.value ? 'nein' : 'ja';
z.knopf.disabled = z.feld.disabled || !z.feld.options.length;
}
/* ---------- Liste ------------------------------------------------------- */
function oeffnen(z) {
schliessen();
if (z.feld.disabled || !z.feld.options.length) return;
const liste = el('div', 'wahl2__liste');
liste.setAttribute('role', 'listbox');
z.liste = liste;
let vorherigeGruppe = null;
for (const o of z.feld.options) {
/* Gruppen (optgroup) mitnehmen -- die Kategoriewahl lebt davon. */
const gruppe = o.parentElement.tagName === 'OPTGROUP' ? o.parentElement.label : null;
if (gruppe && gruppe !== vorherigeGruppe) {
liste.append(el('p', 'wahl2__gruppe', gruppe));
vorherigeGruppe = gruppe;
}
const zeile = el('button', 'wahl2__eintrag', o.textContent);
zeile.type = 'button';
zeile.setAttribute('role', 'option');
zeile.setAttribute('aria-selected', o.selected ? 'true' : 'false');
if (o.selected) zeile.dataset.an = 'ja';
if (o.disabled) { zeile.disabled = true; zeile.dataset.aus = 'ja'; }
zeile.addEventListener('click', () => {
if (o.disabled) return;
z.feld.value = o.value;
/* Von Hand ausloesen: Ein per Skript gesetzter Wert meldet sich
nicht von selbst, und die Seiten haengen an `change`. */
z.feld.dispatchEvent(new Event('change', { bubbles: true }));
zeige(z);
schliessen();
z.knopf.focus();
});
liste.append(zeile);
}
document.body.append(liste);
stelle(z);
z.knopf.setAttribute('aria-expanded', 'true');
aktiv = z;
const erster = liste.querySelector('.wahl2__eintrag[data-an="ja"]')
|| liste.querySelector('.wahl2__eintrag:not([disabled])');
erster?.focus();
erster?.scrollIntoView({ block: 'nearest' });
}
/* Position berechnen. Passt die Liste nicht mehr nach unten, klappt
sie nach oben -- sonst steht sie halb ausserhalb des Fensters. */
function stelle(z) {
const k = z.knopf.getBoundingClientRect();
const liste = z.liste;
liste.style.minWidth = k.width + 'px';
liste.style.left = Math.min(k.left, innerWidth - liste.offsetWidth - 12) + 'px';
const platzUnten = innerHeight - k.bottom;
if (platzUnten < 220 && k.top > platzUnten) {
liste.style.top = Math.max(12, k.top - liste.offsetHeight - 6) + 'px';
} else {
liste.style.top = (k.bottom + 6) + 'px';
liste.style.maxHeight = Math.max(140, platzUnten - 18) + 'px';
}
}
function schliessen() {
if (!aktiv) return;
aktiv.liste?.remove();
aktiv.knopf.setAttribute('aria-expanded', 'false');
aktiv.liste = null;
aktiv = null;
}
/* ---------- Bedienung ---------------------------------------------------- */
document.addEventListener('keydown', (e) => {
if (!aktiv) return;
const eintraege = [...aktiv.liste.querySelectorAll('.wahl2__eintrag:not([disabled])')];
const wo = eintraege.indexOf(document.activeElement);
if (e.key === 'Escape') { e.preventDefault(); const k = aktiv.knopf; schliessen(); k.focus(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); eintraege[Math.min(wo + 1, eintraege.length - 1)]?.focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); eintraege[Math.max(wo - 1, 0)]?.focus(); }
else if (e.key === 'Home') { e.preventDefault(); eintraege[0]?.focus(); }
else if (e.key === 'End') { e.preventDefault(); eintraege.at(-1)?.focus(); }
else if (e.key.length === 1 && /\S/.test(e.key)) {
/* Tippen springt zum naechsten passenden Eintrag -- so wie man es
von einem Auswahlfeld kennt. */
const b = e.key.toLowerCase();
const ab = eintraege.slice(wo + 1).concat(eintraege.slice(0, wo + 1));
ab.find((x) => x.textContent.trim().toLowerCase().startsWith(b))?.focus();
}
});
document.addEventListener('mousedown', (e) => {
if (!aktiv) return;
if (aktiv.liste.contains(e.target) || aktiv.knopf.contains(e.target)) return;
schliessen();
});
addEventListener('resize', schliessen);
addEventListener('scroll', schliessen, true);
/* ---------- Aufwerten, jetzt und spaeter --------------------------------- */
const alle = () => document.querySelectorAll('select:not(.wahl2__echt)');
const laufen = () => { for (const f of alle()) werteAuf(f); };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', laufen);
} else {
laufen();
}
new MutationObserver(() => laufen()).observe(document.documentElement, {
childList: true, subtree: true,
});
})();