From 7c81b17f9cca386cd1dec2d846d2e0e45ddf57d2 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 31 Aug 2026 12:23:49 +0200 Subject: [PATCH] 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 bleibt im Dokument -- es haelt den Wert und macht + jedes vorhandene `feld.value` weiter richtig. Es wird nur unsichtbar + gemacht; sichtbar ist der Knopf darueber. + + Bewusst NICHT display:none: Ein so verstecktes Feld faellt aus der + Formularpruefung des Browsers heraus, und `required` wuerde stumm + nicht mehr greifen. + =================================================================== */ + +.wahl2 { position: relative; display: block; } + +.wahl2__echt { + position: absolute; inset: 0; + opacity: 0; pointer-events: none; + /* Nur ein Pixel gross, damit ein Fehlerhinweis des Browsers noch am + richtigen Ort erscheint. */ + width: 1px; height: 1px; +} + +.wahl2__knopf { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + width: 100%; padding: 10px 12px; cursor: pointer; + background: linear-gradient(#141c28, #101722); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; text-align: left; + transition: border-color .16s ease, background .16s ease; +} +.wahl2__knopf:hover:not([disabled]) { + border-color: rgba(255, 255, 255, .24); + background: linear-gradient(#18212f, #141b27); +} +.wahl2__knopf:focus-visible, +.wahl2__knopf[aria-expanded="true"] { + outline: none; + border-color: rgba(63, 189, 245, .65); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .16); +} +.wahl2__knopf[disabled] { opacity: .5; cursor: default; } +.wahl2__knopf[data-leer="ja"] .wahl2__text { color: var(--text-still); } +.wahl2__text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wahl2__pfeil { + width: 16px; height: 16px; flex: none; + fill: none; stroke: var(--text-still); stroke-width: 1.8; + stroke-linecap: round; stroke-linejoin: round; + transition: transform .18s ease; +} +.wahl2__knopf[aria-expanded="true"] .wahl2__pfeil { transform: rotate(180deg); } + +/* Die Liste haengt am Fenster, nicht am Elternelement -- in einer Karte + mit overflow: hidden waere sie sonst abgeschnitten. */ +.wahl2__liste { + position: fixed; z-index: 90; + min-width: 180px; max-width: min(420px, calc(100vw - 24px)); + overflow-y: auto; padding: 5px; + background: #0d1420; + border: 1px solid rgba(63, 189, 245, .3); + border-radius: 11px; + box-shadow: 0 18px 44px -16px rgba(0, 0, 0, .85); +} +.wahl2__gruppe { + margin: 7px 8px 3px; font-size: .68rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--akzent); +} +.wahl2__gruppe:first-child { margin-top: 3px; } + +.wahl2__eintrag { + display: block; width: 100%; padding: 8px 11px; cursor: pointer; + background: transparent; border: 0; border-radius: 7px; + color: var(--text-leise); font: inherit; font-size: .88rem; text-align: left; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.wahl2__eintrag:hover:not([disabled]) { background: rgba(255, 255, 255, .06); color: var(--text); } +.wahl2__eintrag:focus-visible { + outline: none; background: rgba(63, 189, 245, .16); color: #fff; +} +.wahl2__eintrag[data-an="ja"] { + color: #fff; font-weight: 600; + background: rgba(63, 189, 245, .12); +} +.wahl2__eintrag[disabled], .wahl2__eintrag[data-aus="ja"] { + color: var(--text-still); cursor: default; +} + +@media (prefers-reduced-motion: reduce) { + .wahl2__knopf, .wahl2__pfeil { transition: none; } +} diff --git a/workspace/assets/js/wahl.js b/workspace/assets/js/wahl.js new file mode 100644 index 00000000..4607dd9d --- /dev/null +++ b/workspace/assets/js/wahl.js @@ -0,0 +1,205 @@ +/* =================================================================== + 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, + }); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index a9524b79..d602e9cf 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -134,7 +134,8 @@ - - + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index d4ca697f..283a752d 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -98,7 +98,8 @@ - - + + + diff --git a/workspace/calls.html b/workspace/calls.html index 3fe73390..d9e4bcd6 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -49,8 +49,9 @@ - - - + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index c577b777..0c69b349 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -88,7 +88,8 @@ - - + + + diff --git a/workspace/index.html b/workspace/index.html index 427b5021..52e01e3c 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 401584cf..73c1dec7 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -94,7 +94,8 @@ - - + + + diff --git a/workspace/personen.html b/workspace/personen.html index 01edc1bf..cf2c50ef 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,7 +93,8 @@ - - + + + diff --git a/workspace/profil.html b/workspace/profil.html index 7f88fd60..1ad95d61 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,7 +127,8 @@ - - + + + diff --git a/workspace/report.html b/workspace/report.html index 18322137..79c04244 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -86,8 +86,9 @@ - - - + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 12864fc1..9b33831f 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,7 +124,8 @@ - - + + + diff --git a/workspace/start.html b/workspace/start.html index a1160587..0be93392 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -56,7 +56,8 @@

- - + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index c774b44a..e10e6c0b 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -65,8 +65,9 @@ - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 26cb7284..b83b9c41 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -170,7 +170,8 @@ - - + + +