Files
dogfather-universe/workspace/assets/js/wahl.js
T
DogFatherGitandClaude Opus 5 95c4599baa Anfuehrungszeichen: 199 falsche Schlusszeichen im sichtbaren Text
Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.

Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.

WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE

Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus

    „<a href="https://…                  ein kaputtes Attribut
    /^["'„»\s]+|["'“«.\s]+$/              einen kaputten Ausdruck
    "… nichts „mal " + "eben …"          eine kaputte Zeichenkette

DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE

  < > = dazwischen        -> HTML-Marke oder Attribut
  endet auf Leerzeichen   -> die Zeichenkette hoert hier auf, der
                             Satz geht in der naechsten Zeile weiter.
                             Ein deutsches Schlusszeichen steht NIE
                             hinter einem Leerzeichen.
  Rueckstrich mittendrin  -> regulaerer Ausdruck
  ${ ohne }               -> mitten in einem Ausdruck

Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.

Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.

Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.

KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.

DIE WACHE DAZU

pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.

GEPRUEFT

  pruef-struktur   59 -> 68 Pruefungen, 0 Fehler
  node --check auf allen geaenderten JS-Dateien
  nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben

  gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
  reaktion 421, support 63, content 45, terminregel 35, treff 85

  Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
  ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
  Ausgabe, keine einzige ein Vergleich mit dem Seitentext.

GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.

Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:56:15 +02:00

650 lines
28 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.
---------------------------------------------------------------------
SUCHEN UND SELBST EINTRAGEN (02.09.2026)
Wunsch: "ich will da auch sachen selber noch eintragen können, also
aussuchen und selbst eintragen."
Zwei Dinge kamen dafuer dazu, und beide sitzen HIER und nicht in einem
zweiten Bauteil daneben. Der erste Anlauf war genau das -- eine eigene
Personenwahl neben dieser hier. Sie hat sich prompt mit ihr gebissen:
Zwei Bauteile haben denselben <select> eingepackt, und das Feld stand
danach in doppelter Huelle. Ein zweites Bauteil haette ausserdem
anders ausgesehen und waere beim naechsten Umbau nur an einer von
beiden Stellen nachgezogen worden.
1. EIN SUCHFELD ueber der Liste, sobald es mehr als acht Eintraege
gibt. Tippen filtert. Gilt fuer JEDES Auswahlfeld -- bei
siebzehn Personen ist Scrollen die falsche Bedienung.
2. FREIE NAMEN, aber nur wo es ausdruecklich erlaubt ist
(`data-frei="ja"`). Dann nimmt die Liste den getippten Text als
eigenen Eintrag an: eine Agentur, eine Marke, ein Gast ohne
Konto. Der Text steht danach in `feld.dataset.extern`, und das
<select> ist leer -- entweder eine Person ODER ein Name, nie
beides. Die Seiten schicken beides getrennt zum Server.
Ohne Erlaubnis bleibt alles beim Alten: filtern ja, erfinden nein.
=================================================================== */
(() => {
'use strict';
const OFFEN = new WeakMap();
let aktiv = null; // gerade offene Liste
/* Ab wann ein Suchfeld erscheint. Bei drei Eintraegen waere es nur im
Weg, bei siebzehn ist Scrollen die falsche Bedienung. */
const SUCHE_AB = 8;
const EXTERN_MAX = 80;
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 extern = z.feld.dataset.extern || '';
const gewaehlt = z.feld.selectedOptions[0];
/* Ein frei eingetragener Name schlaegt die Auswahl -- das <select>
steht dann auf "leer", und ohne diese Zeile zeigte der Knopf den
Platzhalter an, obwohl etwas eingetragen ist. */
z.text.textContent = extern || (gewaehlt ? gewaehlt.textContent : '');
z.knopf.dataset.leer = extern || (gewaehlt && gewaehlt.value) ? 'nein' : 'ja';
z.knopf.dataset.extern = extern ? 'ja' : 'nein';
/* Bei erlaubten freien Namen darf der Knopf NICHT abschalten, wenn
die Liste leer ist -- man kann ja trotzdem etwas eintragen. */
z.knopf.disabled = z.feld.disabled
|| (!z.feld.options.length && z.feld.dataset.frei !== 'ja');
hinweisZeigen(z);
}
/* Der Satz unter dem Feld erscheint NUR, wenn wirklich ein freier
Name drinsteht. Ein dauerhaft sichtbarer Warnsatz wird nach zwei
Tagen ueberlesen. */
function hinweisZeigen(z) {
const text = z.feld.dataset.extern ? (z.feld.dataset.freiHinweis || '') : '';
if (!text) { z.hinweis?.remove(); z.hinweis = null; return; }
if (!z.hinweis) {
z.hinweis = el('p', 'wahl2__hinweis');
z.knopf.parentNode.append(z.hinweis);
}
z.hinweis.textContent = text;
}
/** Einen frei eingetragenen Namen setzen (oder mit '' wieder loeschen).
* Loest `change` aus, damit die Seiten davon erfahren -- sie haengen
* alle daran. */
function externSetzen(z, text) {
const sauber = String(text || '').replace(/\s+/g, ' ').trim().slice(0, EXTERN_MAX);
z.feld.dataset.extern = sauber;
if (sauber) z.feld.value = '';
z.feld.dispatchEvent(new Event('change', { bubbles: true }));
zeige(z);
}
/* ---------- Liste ------------------------------------------------------- */
function oeffnen(z) {
schliessen();
const frei = z.feld.dataset.frei === 'ja';
if (z.feld.disabled || (!z.feld.options.length && !frei)) return;
const liste = el('div', 'wahl2__liste');
liste.setAttribute('role', 'listbox');
z.liste = liste;
const zeilen = el('div', 'wahl2__zeilen');
/* Ein Suchfeld gibt es, sobald die Liste lang wird -- oder immer,
wenn man auch selbst etwas eintragen darf. Bei drei Eintraegen
waere es nur im Weg. */
let suchfeld = null;
if (frei || z.feld.options.length > SUCHE_AB) {
suchfeld = el('input', 'wahl2__suche');
suchfeld.type = 'text';
suchfeld.autocomplete = 'off';
suchfeld.spellcheck = false;
suchfeld.placeholder = frei ? 'Suchen oder Namen eintippen …' : 'Suchen …';
suchfeld.setAttribute('aria-label', suchfeld.placeholder);
suchfeld.value = z.feld.dataset.extern || '';
suchfeld.addEventListener('input', () => fuellen(suchfeld.value));
suchfeld.addEventListener('keydown', (e) => {
/* Enter nimmt den ersten Treffer -- oder, wenn es keinen gibt
und freie Namen erlaubt sind, den getippten Text. Ohne das
muesste man nach dem Tippen noch klicken. */
if (e.key === 'Enter') {
e.preventDefault();
zeilen.querySelector('.wahl2__eintrag:not([disabled])')?.click();
}
});
liste.append(suchfeld);
}
liste.append(zeilen);
function waehle(wert) {
z.feld.dataset.extern = '';
z.feld.value = wert;
/* 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();
}
function fuellen(suche = '') {
const s = suche.trim().toLowerCase();
zeilen.replaceChildren();
/* Der getippte Text als eigener Eintrag -- ganz oben, damit man
ihn ohne Scrollen erreicht. Nur, wenn er nicht sowieso genau
auf einen vorhandenen Eintrag passt. */
const genauSo = [...z.feld.options]
.some((o) => o.value && o.textContent.trim().toLowerCase() === s);
if (frei && s && !genauSo) {
/* DER TEXT KOMMT IN EINEN SPAN -- wie bei jedem anderen
Eintrag auch. Direkt im Knopf wird er seit dem
08.09.2026 HART abgeschnitten statt mit „…" gekuerzt:
Die Zeile ist `display: flex`, und `text-overflow`
wirkt nicht auf ein anonymes Flex-Kind. Gemessen ist
der Unterschied nicht in Zahlen zu sehen (`scrollWidth`
ist in beiden Faellen gleich) -- nur im Bild. */
const eigen = el('button', 'wahl2__eintrag wahl2__eintrag--eigen');
eigen.append(el('span', 'wahl2__name', `„${suche.trim()}“ eintragen`));
eigen.type = 'button';
eigen.setAttribute('role', 'option');
eigen.addEventListener('click', () => {
externSetzen(z, suche);
schliessen();
z.knopf.focus();
});
zeilen.append(eigen);
}
let vorherigeGruppe = null;
let treffer = 0;
for (const o of z.feld.options) {
if (s && !o.textContent.toLowerCase().includes(s)) continue;
treffer++;
/* Gruppen (optgroup) mitnehmen -- die Kategoriewahl lebt davon. */
const gruppe = o.parentElement.tagName === 'OPTGROUP' ? o.parentElement.label : null;
if (gruppe && gruppe !== vorherigeGruppe) {
zeilen.append(el('p', 'wahl2__gruppe', gruppe));
vorherigeGruppe = gruppe;
}
/* DAS GESICHT ZUM NAMEN (07.09.2026).
Wunsch Filipe: "jeder soll auch immer das profilbild von denen
sehen mit denen sie verbunden sind ... ich seh als
manager/betreuer von tili ihr profilbild immer noch nicht."
Der Server liefert die Bildadresse seit heute mit
(/api/personen). Gezeichnet wurde sie nirgends -- deshalb
aenderte sich fuer ihn nichts. Hier ist der wirksamste Ort:
Diese Liste ist die Personenauswahl in Aufgaben, Terminen,
Dateien, Bereichen und Calls. Ein Bild macht aus einer Liste
von Namen eine Liste von Menschen, und man trifft den
richtigen, ohne zu lesen.
Der ANFANGSBUCHSTABE bleibt als Unterlage LIEGEN und wird
nicht ersetzt: Laedt das Bild nicht (Verbindung weg, Datei
geloescht), steht dort weiterhin etwas Sinnvolles statt eines
kaputten Bildsymbols. Genau so macht es die Kopfleiste auch.
Wo es kein `data-bild` gibt (Kategorien, Saeulen, Takte),
bleibt die Zeile wie bisher -- die Auswahl weiss nicht, dass
sie manchmal Personen zeigt, und muss es auch nicht. */
const zeile = el('button', 'wahl2__eintrag');
if (o.dataset.bild || o.dataset.person === 'ja') {
const p = el('span', 'wahl2__bild');
p.setAttribute('aria-hidden', 'true');
p.textContent = (o.textContent || '?').trim().charAt(0).toUpperCase();
if (o.dataset.bild) {
const img = document.createElement('img');
img.src = o.dataset.bild;
img.alt = '';
img.addEventListener('error', () => img.remove());
p.append(img);
}
zeile.append(p);
}
zeile.append(el('span', 'wahl2__name', o.textContent));
zeile.type = 'button';
zeile.setAttribute('role', 'option');
const an = o.selected && !z.feld.dataset.extern;
zeile.setAttribute('aria-selected', an ? 'true' : 'false');
if (an) zeile.dataset.an = 'ja';
if (o.disabled) { zeile.disabled = true; zeile.dataset.aus = 'ja'; }
zeile.addEventListener('click', () => { if (!o.disabled) waehle(o.value); });
zeilen.append(zeile);
}
/* Ein leeres Ergebnis muss etwas sagen. Eine wortlos leere Liste
sieht aus, als sei etwas kaputt. */
if (!treffer && !(frei && s)) {
zeilen.append(el('p', 'wahl2__leer',
s ? 'Nichts gefunden.' : 'Nichts zur Auswahl.'));
}
}
fuellen(suchfeld ? suchfeld.value : '');
/* ==== WOHIN DIE LISTE GEHAENGT WIRD (23.09.2026) ===============
Hier stand nur "document.body.append(liste)". Das geht ueberall
gut -- ausser in einem modalen Dialog.
Ein Dialog, der mit showModal() geoeffnet wurde, liegt in der
TOP LAYER: einer Ebene ueber dem ganzen Dokument. Kein z-index
der Welt holt etwas aus dem Body davor -- die EBENE entscheidet,
nicht die Zahl. Die Liste war also da, lag aber dahinter.
Gemessen: Ein Klick in die Mitte der aufgeklappten Liste traf
DIALOG.dialog. Filipe: "dan erscheint die auswahl im
hintergrund."
In den Dialog gehaengt liegt sie in derselben Ebene wie er --
und damit davor.
UND WARUM DIE POSITION JETZT INLINE STEHT: In module.css steht
".dialog > * { position: relative }". Das ist (0,1,0), genauso
stark wie ".wahl2__liste { position: fixed }" in gate.css -- und
module.css wird spaeter geladen. Als direktes Kind des Dialogs
waere die Liste also "relative" geworden und mitten im Formular
gelandet statt am Knopf.
Dieselbe Falle wie heute frueh bei der Kopfleiste: eine Regel,
die etwas anderes wollte, nimmt die Positionierung mit. Diesmal
vorher gemessen statt hinterher gesucht. Der Inline-Stil schlaegt
beide Regeln, ohne eine davon anzufassen -- und gilt auch fuer
jeden kuenftigen Dialog. */
const dlg = z.knopf.closest("dialog");
/* ==== IN DEN DIALOG, NICHT DANEBEN (24.09.2026) ================
Filipe: „bei suchen kann man nichts reinschreiben."
Er hatte recht, und es war die Folge des Umbaus von gestern.
Gemessen: Das Suchfeld war da, der Fokus landete nicht darin,
ein getipptes Zeichen kam nicht an.
DER GRUND IST DER FOKUS-KAEFIG. Ein Dialog, der mit showModal()
geoeffnet wurde, sperrt den Fokus auf seinen eigenen DOM-Baum
ein. Die Liste hing am <body> -- sichtbar (der Popover hebt sie
in die Top Layer), aber ausserhalb des Kaefigs und damit nicht
bedienbar. Klicken ging, Tippen nicht: Ein Klick braucht keinen
Fokus, eine Eingabe schon.
WARUM SIE FRUEHER DRAUSSEN HING: Als direktes Kind des Dialogs
wurde sie vom clip-path der abgeschraegten Ecke abgeschnitten
(siehe der Absatz weiter unten). Das galt aber fuer eine Liste
im normalen Fluss. EIN POPOVER WIRD IN DIE TOP LAYER GEHOBEN
und dort gezeichnet -- der Beschnitt des Elternteils erreicht
es nicht mehr. Der DOM-Baum bleibt trotzdem der des Dialogs,
und damit gilt auch sein Fokus.
Beides zusammen loest, was einzeln nicht ging:
draussen -> sichtbar, aber nicht bedienbar
drinnen -> bedienbar, aber abgeschnitten
drinnen + Popover -> beides. */
(dlg || document.body).append(liste);
liste.style.position = "fixed";
/* IN EINEM DIALOG BRAUCHT DIE LISTE DIE TOP LAYER.
Erster Versuch war, sie IN den Dialog zu haengen. Sie lag dann
zwar davor -- und wurde am unteren Rand abgeschnitten: .dialog
traegt ein clip-path fuer die abgeschraegte Ecke, und ein
clip-path beschneidet ALLE Nachkommen, auch "position: fixed".
Gemessen und auf dem Bildschirmfoto gesehen: Die Liste endete
mitten im Wort "Events".
Damit ging beides nicht: draussen liegt sie hinter dem Dialog
(Top Layer), drinnen wird sie beschnitten (clip-path).
Der Popover ist genau dafuer gemacht: Er hebt ein Element in
dieselbe Ebene wie den Dialog, OHNE es zu seinem Kind zu machen.
Kein Beschnitt, kein z-index-Wettlauf, und der Browser raeumt
ihn beim Schliessen selbst weg.
WENN ER FEHLT, bleibt die Liste im Body -- also so, wie sie
vorher ueberall war. Ausserhalb eines Dialogs aendert sich
nichts. */
if (dlg && dlg.open && typeof liste.showPopover === "function") {
liste.setAttribute("popover", "manual");
try { liste.showPopover(); } catch { /* dann eben ohne */ }
/* ==== UND DAS SCROLLEN SELBST UEBERNEHMEN (24.09.2026) =======
Filipe: "das hoch und runter scollen geht nicht in der liste
da."
Gemessen, alle drei Verdaechtigen einzeln:
scrollTop von Hand setzen geht (80)
das wheel-Ereignis kommt an
overscroll-behavior unauffaellig
Also kann der Kasten scrollen, und das Ereignis erreicht ihn
-- der Browser fuehrt es nur nicht aus. Der Grund ist der
modale Dialog: Er unterdrueckt das Scrollen von allem, was
ausserhalb von ihm liegt. Ein Popover in der Top Layer bleibt
zwar anklickbar (das hat die Messung gestern gezeigt), aber
gescrollt wird darin nicht.
DIE LISTE IN DEN DIALOG ZU HAENGEN LOEST ES NICHT -- dort
schneidet das clip-path der abgeschraegten Ecke sie ab, genau
das war gestern der erste, falsche Versuch.
Also uebernimmt die Liste das Scrollen selbst: Das Ereignis
ist da, der Weg ist frei, es fehlt nur die Ausfuehrung. Nur
im Popover-Fall -- ueberall sonst macht der Browser es
richtig, und ein zweiter Scroller danebenliegend waere die
Sorte Doppelung, die irgendwann ruckelt. */
const rollbar = liste.querySelector(".wahl2__zeilen") || liste;
/* AM DOKUMENT, NICHT AN DER LISTE -- und zwar in der
Einfangphase.
An der Liste zu horchen klingt naeher dran und ist es nicht:
Welches Element ein Rad-Ereignis bekommt, entscheidet der
Treffertest, und der meldet bei einem Popover ueber einem
modalen Dialog den DIALOG. Gemessen: Ein Ereignis, das direkt
auf der Liste ausgeloest wird, rollt sie (100 -> 209); eines
ueber Bildschirmkoordinaten kommt gar nicht erst an.
Am Dokument laeuft jedes Rad-Ereignis vorbei, egal wer den
Treffertest gewinnt. Ob es die Liste MEINT, entscheidet dann
die Lage des Zeigers -- das ist dieselbe Frage, nur an einer
Stelle beantwortet, an der sie beantwortbar ist. */
z.beiRad = (ereignis) => {
const r = liste.getBoundingClientRect();
const drin = ereignis.clientX >= r.left && ereignis.clientX <= r.right
&& ereignis.clientY >= r.top && ereignis.clientY <= r.bottom;
if (!drin) return;
const platz = rollbar.scrollHeight - rollbar.clientHeight;
if (platz <= 0) return;
const vorher = rollbar.scrollTop;
rollbar.scrollTop = Math.max(0, Math.min(platz, vorher + ereignis.deltaY));
/* Nur wenn wirklich gerollt wurde: Am Ende der Liste soll das
Rad nichts weiter tun -- und vor allem nicht die Seite
darunter bewegen. */
if (rollbar.scrollTop !== vorher) ereignis.preventDefault();
};
document.addEventListener("wheel", z.beiRad,
{ passive: false, capture: true });
}
stelle(z);
z.knopf.setAttribute('aria-expanded', 'true');
aktiv = z;
if (suchfeld) {
suchfeld.focus();
suchfeld.select();
} else {
const erster = zeilen.querySelector('.wahl2__eintrag[data-an="ja"]')
|| zeilen.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;
/* Den Rad-Horcher abmelden. Ohne das bliebe bei jedem Oeffnen
einer mehr am Dokument haengen -- und jeder von ihnen faende
seine Liste nicht mehr. */
if (aktiv.beiRad) {
document.removeEventListener("wheel", aktiv.beiRad, { capture: true });
aktiv.beiRad = null;
}
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);
/* Steht der Zeiger im Suchfeld, gehoeren die Tasten DORTHIN. Ohne
diese Unterscheidung haette der Sprung-zum-Buchstaben unten jeden
Tastendruck abgefangen: Man tippt ein "a", und der Zeiger springt
aus dem Suchfeld auf einen Eintrag -- Suchen waere unmoeglich. */
const imSuchfeld = document.activeElement?.classList.contains('wahl2__suche');
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();
/* Vom ersten Eintrag zurueck ins Suchfeld -- sonst sitzt man
darin fest und kommt an den getippten Text nicht mehr heran. */
if (wo === 0 && aktiv.liste.querySelector('.wahl2__suche')) {
aktiv.liste.querySelector('.wahl2__suche').focus();
} else eintraege[Math.max(wo - 1, 0)]?.focus();
}
else if (imSuchfeld) { /* alles Weitere gehoert dem Suchfeld */ }
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);
/* SCROLLEN IN DER LISTE DARF SIE NICHT SCHLIESSEN (04.09.2026, von Filipe gemeldet:
"wenn ich da scollen will geht das immer zu").
Hier stand `addEventListener('scroll', schliessen, true)`. Das dritte Argument
bedeutet: in der EINFANGPHASE lauschen -- und damit auf JEDES Scrollen im ganzen
Dokument, auch auf das in der Liste selbst. Wer die Namensliste herunterrollen
wollte, schloss sie damit im selben Moment. Bei acht Eintraegen faellt das kaum
auf, beim ganzen Team ist die Liste unbedienbar.
Zumachen muss sie trotzdem, wenn die SEITE scrollt: Sie haengt position:fixed am
Knopf und schwaemme sonst davon. Deshalb wird jetzt unterschieden, WO gescrollt
wurde. Ein Scroll-Ereignis der Seite hat document als Ziel -- das ist nicht in der
Liste enthalten, also schliesst sie wie bisher. */
addEventListener('scroll', (e) => {
if (!aktiv) return;
const ziel = e.target;
if (ziel instanceof Node && aktiv.liste?.contains(ziel)) return;
schliessen();
}, true);
/* Ein Formular-reset() leert das <select>, kennt aber den frei
eingetragenen Namen nicht -- ohne das hier stuende er nach dem
Abbrechen noch da und behauptete eine Zuordnung, die es nicht mehr
gibt. */
document.addEventListener('reset', (e) => {
const felder = e.target?.querySelectorAll?.('select[data-extern]');
if (!felder) return;
setTimeout(() => {
for (const f of felder) {
f.dataset.extern = '';
const z = OFFEN.get(f);
if (z) zeige(z);
}
}, 0);
});
/* ---------- 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,
});
/* ---------- Freie Namen erlauben -----------------------------------------
Die Seiten rufen das auf, nachdem sie ihre Personen geladen haben:
window.personenwahl?.('f-creator', { hinweis: '…' });
Danach nimmt genau dieses Feld auch einen getippten Namen an. Der
Hinweis steht unter dem Feld, sobald wirklich einer eingetragen ist.
Als Attribute am Feld und nicht als Zustand hier drinnen: So
ueberlebt die Einstellung, wenn die Seite das Feld spaeter neu
aufbaut -- und man sieht ihr im Browser-Werkzeug an, dass sie
gesetzt ist. */
window.personenwahl = function (feld, optionen = {}) {
const f = typeof feld === 'string' ? document.getElementById(feld) : feld;
if (!f) return null;
f.dataset.frei = optionen.frei === false ? 'nein' : 'ja';
if (optionen.hinweis) f.dataset.freiHinweis = optionen.hinweis;
if (!f.dataset.extern) f.dataset.extern = '';
/* Falls das Feld schon aufgewertet ist, den Knopf neu beschriften --
"disabled" haengt jetzt an einer anderen Bedingung. */
const z = OFFEN.get(f);
if (z) zeige(z);
return {
wert: () => f.value,
extern: () => f.dataset.extern || '',
/* SETZEN -- fehlte bis zum 08.09.2026, und das Fehlen hat Daten
gekostet.
Die Schnittstelle konnte lesen (`wert`, `extern`) und leeren
(`zuruecksetzen`), aber nicht FUELLEN. Ein Bearbeiten-Formular
konnte einen frei eingetragenen Namen deshalb gar nicht
anzeigen -- es blieb leer. Beim Speichern wurde dann der leere
Wert mitgeschickt und ueberschrieb den vorhandenen: Wer einen
Termin mit "BananaStift" bearbeitete und speicherte, hatte den
Namen danach geloescht, ohne ihn je gesehen zu haben.
Genau das meinte Filipe mit "damit ich auch immer sehe okee das
war alles und das aender ich" (screen19).
Zwei Faelle, und sie schliessen sich aus: Entweder steht dort
eine PERSON aus der Liste (dann traegt das Feld ihre Nummer),
oder ein FREIER NAME (dann ist die Nummer leer und der Name
steht in `dataset.extern`). Beides gleichzeitig gibt es nicht --
deshalb setzt das eine das andere zurueck. */
setzen(wert, externText) {
const zz = OFFEN.get(f);
const frei = String(externText || '').replace(/\s+/g, ' ').trim().slice(0, EXTERN_MAX);
if (frei) {
f.value = '';
f.dataset.extern = frei;
} else {
f.dataset.extern = '';
f.value = wert == null ? '' : String(wert);
}
if (zz) zeige(zz);
return this;
},
zuruecksetzen() {
f.value = '';
f.dataset.extern = '';
const zz = OFFEN.get(f);
if (zz) zeige(zz);
},
};
};
})();