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]>
650 lines
28 KiB
JavaScript
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);
|
|
},
|
|
};
|
|
};
|
|
})();
|