Jedes merkte sich nur seine eigene Liste. Nebeneinander geöffnet lagen sie übereinander, und keine wusste von den anderen. Ein Auswahlfeld ist ein Menü, und von denen ist höchstens eines offen — jetzt modulweit vermerkt, genau wie beim Kontextmenü. Escape schliesst zusätzlich von überall, nicht nur mit dem Fokus in der Liste. Dazu stand unter jeder Mediathek „0 Titel freigeben". Der Knopf wurde über `hidden` versteckt — das Attribut wirkt über `display: none` mit der Spezifität von Null, und `.btn` setzt `display: inline-flex`. Die Klasse `.hidden` trägt ein `!important` und setzt sich durch. Und das Favicon kommt jetzt aus `icon.png`, dem gerenderten App-Icon selbst. Meine nachgezeichnete Fassung war falsch: die Grafik füllt die Fläche, statt klein in der Mitte zu sitzen. Ein Icon, das dem echten nur ähnelt, driftet beim nächsten Entwurf wieder auseinander. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
7.8 KiB
TypeScript
211 lines
7.8 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();
|
|
};
|
|
|
|
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(); });
|
|
window.addEventListener('scroll', () => 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(); },
|
|
};
|
|
}
|