Filipe: "wenn ich auf zustaendigkeit druecke dan erscheint die auswahl
im hintergrund kontrollier das."
KONTROLLIERT, UND ER HAT RECHT. Gemessen im Browser: Der Kanal-Dialog
ist modal, die Liste hing an BODY, und ein Klick in ihre Mitte traf
DIALOG.dialog -- also den Dialog, nicht die Liste. Sie war da, sichtbar
war sie nicht, bedienbar erst recht nicht.
DER GRUND IST EINE EBENE, KEINE ZAHL. Ein Dialog aus showModal() liegt
in der TOP LAYER, einer Schicht ueber dem ganzen Dokument. Kein
z-index holt etwas aus dem Body davor; die Ebene entscheidet.
DER ERSTE VERSUCH WAR FALSCH, und das Bildschirmfoto hat es gezeigt:
Die Liste IN den Dialog zu haengen bringt sie zwar nach vorn -- und
laesst sie am unteren Rand abschneiden. `.dialog` traegt ein
clip-path fuer die abgeschraegte Ecke, und ein clip-path beschneidet
ALLE Nachkommen, auch "position: fixed". Die Liste endete mitten im
Wort "Events".
Damit ging beides nicht: draussen dahinter, drinnen beschnitten.
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. Fehlt er im Browser, bleibt alles wie bisher -- ausserhalb
eines Dialogs aendert sich ohnehin nichts.
Die drei Zeilen in gate.css nehmen die Vorgaben zurueck, die ein
Popover mitbringt (Rahmen, Polster, und "inset: 0" plus "margin: auto",
was ihn in die Bildmitte stellt).
UND MEINE MESSUNG WAR ZUERST FALSCH, nicht der Code: Sie fragte
document.elementFromPoint und bekam DIALOG -- auch als die Liste
sichtbar darueber lag. Top-Layer-Elemente erfasst elementFromPoint
nicht verlaesslich. Gemessen wird jetzt, was ein Mensch tut: auf einen
Eintrag tippen und nachsehen, ob er ankommt. Er kommt an
("chat -> events"), und die Liste schliesst sich danach.
DAZU EINE EIGENE SCHLAMPEREI VON VORHIN: Die neuen Handy-Kacheln auf
"Eure Aufgaben" hatten .7rem = 11,2 px. Die Grenze des Hauses liegt
bei 11,5 px, und sie steht dort aus einem Grund -- Augenschonung ist
Pflicht, nicht Geschmack. pruef-css-klassen hat es gefangen ("43
Stellen unter 11,5 px, eine mehr als die Grundlinie 42"). Genau dafuer
zaehlt sie mit. Jetzt .75rem, und die Zahl steht wieder bei 42.
Gemessen: 7 Messungen am Dialog ohne Befund, css-klassen wieder in
Ordnung.
NICHT VON HEUTE ABEND, aber gefunden: pruef-chatkachel meldet "mit
allen 13 Toenen (2)". Seit Commit 7a674963 liegen die meisten Toene
auf einem Ring und nur der Rest im Gitter; die Pruefung zaehlt nur das
Gitter. Sie ist damit dauerhaft rot und gehoert nachgezogen.
Co-Authored-By: Claude Opus 5 <[email protected]>
550 lines
23 KiB
JavaScript
550 lines
23 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) {
|
|
const eigen = el('button', 'wahl2__eintrag wahl2__eintrag--eigen',
|
|
`„${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");
|
|
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 */ }
|
|
}
|
|
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;
|
|
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);
|
|
},
|
|
};
|
|
};
|
|
})();
|