/* =================================================================== Auswahlfelder, die man gestalten kann. WARUM ES DAS GIBT: Ein aufgeklapptes 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 = ''; 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, }); })();