import { icons } from './icons'; export type Option = { wert: string; text: string }; /** Ein Auswahlfeld, das aussieht wie der Rest der Oberfläche. * * **Warum nicht `` 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('.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 `` 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('.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('.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(); }, }; }