Files
Flaro/web/app/src/auswahl.ts
T
ScarriffleandClaude Opus 5 ce38da1376 Die Auswahlliste klappte hinter dem Dialog auf
Ein `<dialog>` mit `showModal()` liegt in der Top-Layer des Browsers,
über allem — unabhängig von `z-index`. Die Liste hing am `body` und
verschwand deshalb hinter genau dem Dialog, in dem ihr Knopf steht.
Jetzt wird sie in den Dialog gehängt, wenn es einen gibt. Mit
`elementFromPoint` nachgeprüft: an ihrer Stelle liegt wirklich ein
Listeneintrag obenauf.

Dazu zwei Stellen, die zu der Vermutung geführt haben, die
Spracheinstellung wirke nicht:

„Übersetzungen holen" stand stur auf en-US. Neben einem Server, der auf
Deutsch steht, liest sich das wie eine Aussage über den Server.
Vorgewählt wird jetzt die eigene Sprache, sonst die erste, die nicht
schon die des Servers ist — und der Text nennt die Serversprache.

Und „Meine Sprache" sagt jetzt deutlich, wenn sie abweicht. Sie ändert
nicht, was der Server holt, nur was man selbst sieht: steht sie auf
Englisch, während der Server auf Deutsch holt, sieht die ganze
Mediathek englisch aus und jedes Auffrischen scheint wirkungslos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 20:03:01 +02:00

228 lines
8.6 KiB
TypeScript

import { icons } from './icons';
export type Option = { wert: string; text: string };
/** Ein Auswahlfeld, das aussieht wie der Rest der Oberfläche.
*
* **Warum nicht `<select>`.** Dessen Liste zeichnet das Betriebssystem, und
* zwar in seinem eigenen Stil: auf einem Mac erscheint mitten in einer
* dunklen Oberfläche ein helles Systemmenü mit abgerundeten Ecken und
* blauer Markierung. Es lässt sich nicht gestalten — kein Rahmen, keine
* Farbe, keine Radien. In einer Oberfläche, die sonst durchgehend eine
* Formensprache spricht, ist es der eine Fleck, der verrät, dass hier nichts
* aufeinander abgestimmt wurde.
*
* **Was dafür selbst gebaut werden muss.** Ein `<select>` bringt viel mit,
* das man leicht vergisst: Tastaturbedienung, Rollen für Hilfsmittel, das
* Schliessen beim Klick daneben. Steht alles unten — ohne das wäre der
* Tausch eine Verschlechterung, nur eine hübschere.
*/
export type Auswahlfeld = {
/** Was in die Seite gehängt wird. */
element: HTMLButtonElement;
/** Der gewählte Wert — wie bei `select.value`. */
wert: string;
/** Wird bei jeder Änderung durch den Benutzer gerufen, nicht beim Setzen. */
beiWechsel: (fn: () => void) => void;
/** Tauscht die Liste aus — für Werte, die erst nachgeladen werden. */
setzen: (optionen: Option[]) => void;
};
/** Das eine offene Auswahlfeld — oder keines.
*
* **Modulweit, nicht je Feld.** Vorher merkte sich jedes Feld nur seine
* eigene Liste; drei Felder nebeneinander liessen sich alle drei aufklappen
* und überlagerten einander. Ein Auswahlfeld ist ein Menü, und von denen ist
* immer höchstens eines offen — genauso hält es das Kontextmenü.
*/
let offenesFeld: { schliessen: () => void } | null = null;
export function alleAuswahlenSchliessen(): void {
offenesFeld?.schliessen();
offenesFeld = null;
}
export function auswahl(optionen: Option[], vorgabe = ''): Auswahlfeld {
let liste = [...optionen];
let wert = vorgabe;
let melden: (() => void) | null = null;
const knopf = document.createElement('button');
knopf.type = 'button';
knopf.className = 'auswahl';
knopf.setAttribute('aria-haspopup', 'listbox');
knopf.setAttribute('aria-expanded', 'false');
const beschriftung = document.createElement('span');
const pfeil = document.createElement('span');
pfeil.className = 'auswahl-pfeil icon';
pfeil.innerHTML = icons.runter;
knopf.append(beschriftung, pfeil);
const beschriften = () => {
beschriftung.textContent = liste.find(o => o.wert === wert)?.text ?? liste[0]?.text ?? '';
};
beschriften();
let offen: HTMLElement | null = null;
function schliessen(): void {
offen?.remove();
offen = null;
knopf.setAttribute('aria-expanded', 'false');
if (offenesFeld === steuerung) offenesFeld = null;
}
const steuerung = { schliessen };
function waehlen(neu: string): void {
const geaendert = neu !== wert;
wert = neu;
beschriften();
schliessen();
knopf.focus();
if (geaendert) melden?.();
}
function oeffnen(): void {
if (offen) { schliessen(); return; }
// Erst das andere zu, dann dieses auf.
alleAuswahlenSchliessen();
offenesFeld = steuerung;
const box = document.createElement('div');
box.className = 'auswahl-liste';
box.setAttribute('role', 'listbox');
liste.forEach(o => {
const e = document.createElement('button');
e.type = 'button';
e.className = 'auswahl-punkt' + (o.wert === wert ? ' gewaehlt' : '');
e.setAttribute('role', 'option');
e.setAttribute('aria-selected', String(o.wert === wert));
e.textContent = o.text;
e.onclick = () => waehlen(o.wert);
box.appendChild(e);
});
// **Die Tasten müssen auch in der Liste wirken.** Nach dem Öffnen sitzt
// der Fokus auf einem Eintrag, nicht mehr auf dem Knopf — Escape lief
// dort ins Leere, und die Liste blieb offen stehen.
box.onkeydown = e => {
if (e.key === 'Escape') { e.preventDefault(); schliessen(); knopf.focus(); return; }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
bewegen(e.key === 'ArrowDown' ? 1 : -1);
return;
}
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
const punkte = [...box.querySelectorAll<HTMLElement>('.auswahl-punkt')];
(e.key === 'Home' ? punkte[0] : punkte[punkte.length - 1])?.focus();
return;
}
// Tab verlässt die Liste — dann hat sie dort nichts mehr zu suchen.
if (e.key === 'Tab') schliessen();
};
// **In den Dialog, wenn es einen gibt.**
//
// Ein `<dialog>` mit `showModal()` liegt in der Top-Layer des Browsers —
// über allem, unabhängig von `z-index`. Eine Liste am `body` verschwand
// deshalb hinter dem Dialog, in dem ihr Knopf steht. Im Dialog selbst
// liegt sie mit ihm in derselben Ebene.
(knopf.closest('dialog') ?? document.body).appendChild(box);
offen = box;
knopf.setAttribute('aria-expanded', 'true');
// **Erst anhängen, dann messen.** Vorher hat die Liste keine Grösse, und
// die Prüfung auf den Bildschirmrand liefe gegen null. Dasselbe Vorgehen
// wie im Kontextmenü, und aus demselben Grund.
const ab = knopf.getBoundingClientRect();
const eigen = box.getBoundingClientRect();
box.style.minWidth = `${ab.width}px`;
box.style.left = `${Math.max(8, Math.min(ab.left, window.innerWidth - eigen.width - 8))}px`;
box.style.top = ab.bottom + eigen.height + 8 > window.innerHeight
? `${Math.max(8, ab.top - eigen.height - 4)}px`
: `${ab.bottom + 4}px`;
box.querySelector<HTMLElement>('.gewaehlt, .auswahl-punkt')?.focus();
}
knopf.onclick = e => { e.stopPropagation(); oeffnen(); };
// **Die Tastatur, die ein `select` mitbrachte.** Hoch und runter wechseln
// auch ohne geöffnete Liste — genau so verhält sich das Original, und wer
// es gewohnt ist, merkt den Tausch sonst sofort.
knopf.onkeydown = e => {
const stelle = liste.findIndex(o => o.wert === wert);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (offen) { bewegen(e.key === 'ArrowDown' ? 1 : -1); return; }
const ziel = liste[Math.min(liste.length - 1, Math.max(0, stelle + (e.key === 'ArrowDown' ? 1 : -1)))];
if (ziel) waehlen(ziel.wert);
return;
}
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
const ziel = e.key === 'Home' ? liste[0] : liste[liste.length - 1];
if (ziel) waehlen(ziel.wert);
return;
}
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oeffnen(); return; }
if (e.key === 'Escape') { schliessen(); return; }
// Anfangsbuchstabe — springt zum nächsten passenden Eintrag.
if (e.key.length === 1 && /\S/.test(e.key)) {
const zeichen = e.key.toLowerCase();
const ab = stelle + 1;
const treffer = [...liste.slice(ab), ...liste.slice(0, ab)]
.find(o => o.text.toLowerCase().startsWith(zeichen));
if (treffer) waehlen(treffer.wert);
}
};
/** Pfeiltasten in der offenen Liste. */
function bewegen(richtung: 1 | -1): void {
if (!offen) return;
const punkte = [...offen.querySelectorAll<HTMLElement>('.auswahl-punkt')];
const jetzt = punkte.indexOf(document.activeElement as HTMLElement);
const ziel = punkte[Math.min(punkte.length - 1, Math.max(0, jetzt + richtung))];
ziel?.focus();
}
// Ein Klick daneben, Escape oder ein Bildlauf schliessen — wie beim
// Kontextmenü. Je Feld registriert und nicht global: es gibt wenige davon,
// und beim Entfernen des Feldes gehen sie mit.
document.addEventListener('click', e => {
if (offen && !offen.contains(e.target as Node) && !knopf.contains(e.target as Node)) {
schliessen();
}
});
document.addEventListener('keydown', e => { if (e.key === 'Escape' && offen) schliessen(); });
// **Scrollen in der Liste schliesst sie nicht.**
//
// Der Zuhörer hängt mit `capture` am Fenster, damit auch ein Bildlauf in
// einem inneren Behälter die Liste schliesst — sie steht `position: fixed`
// und bliebe sonst neben dem Knopf stehen, der längst weggescrollt ist.
// Nur: eine Liste mit zwanzig Genres scrollt selbst, und genau dieses
// Ereignis kam hier ebenfalls an. Wer die Liste durchblättern wollte,
// schloss sie dabei.
window.addEventListener('scroll', e => {
if (offen && e.target instanceof Node && offen.contains(e.target)) return;
schliessen();
}, { passive: true, capture: true });
return {
element: knopf,
get wert() { return wert; },
set wert(neu: string) { wert = neu; beschriften(); },
beiWechsel: fn => { melden = fn; },
setzen: neue => { liste = [...neue]; beschriften(); },
};
}