Dropdown mit Alpine.js
Ein Dropdown das sich nur mit der Maus bedienen lässt, ist für Tastatur-Nutzer und Screen-Reader-Nutzer nutzlos. Das WAI-ARIA Listbox-Pattern definiert präzise, wie Pfeiltasten, Home, End, Escape und Type-Ahead in einem Dropdown funktionieren müssen – und Alpine.js macht die Implementierung überraschend elegant.
Inhaltsverzeichnis
- 1. WAI-ARIA Listbox-Pattern: was der Standard vorschreibt
- 2. Grundstruktur: Trigger, Listbox und Optionen
- 3. Keyboard-Handler: Pfeiltasten, Home, End, Escape
- 4. Type-Ahead: Direktnavigation per Buchstabe
- 5. ARIA-Attribute: die vollständige Implementierung
- 6. Fokus-Management und Scroll-into-View
- 7. Mehrspaltige und gruppierte Dropdowns
- 8. Combobox: Suche + Dropdown kombiniert
- 9. Keyboard-Pattern-Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. WAI-ARIA Listbox-Pattern: was der Standard vorschreibt
Die WAI-ARIA Authoring Practices definieren für Listboxes und Dropdowns ein genaues Keyboard-Interaction-Pattern. Ein role="listbox"-Element muss folgende Tastaturinteraktionen unterstützen: Pfeiltaste nach unten bewegt den Fokus zur nächsten Option, Pfeiltaste nach oben zur vorherigen. Home setzt den Fokus auf die erste Option, End auf die letzte. Enter und Space wählen die fokussierte Option aus. Escape schließt die Listbox ohne Auswahl. Printable Characters (Buchstaben und Ziffern) lösen Type-Ahead-Navigation aus.
Was dieses Muster von einem einfachen Dropdown mit @click.outside="close()" unterscheidet, ist die Vollständigkeit: Jede Tastaturinteraktion ist spezifiziert und erwartet. Screen-Reader-Nutzer verlassen sich darauf, weil alle Listboxes denselben Standard einhalten müssen. Ein Dropdown, das nur Maus-Klicks unterstützt, ist für Tastaturnutzer funktional unzugänglich – und verstößt gegen WCAG 2.1 Kriterium 2.1.1 (Keyboard).
In Alpine.js lässt sich dieses Pattern komplett ohne externe Bibliotheken implementieren. Die Komponentenlogik besteht aus dem Zustandsmanagement (open, activeIndex, selectedIndex), den Keyboard-Handlern und dem ARIA-Attribut-Binding. Alpine.js' reaktives System stellt sicher, dass Fokus und ARIA-Attribute immer synchron mit dem activeIndex sind – ohne manuelle DOM-Manipulation.
2. Grundstruktur: Trigger, Listbox und Optionen
Die HTML-Struktur eines keyboard-navigierbaren Dropdowns in Alpine.js besteht aus drei Teilen. Der Trigger-Button öffnet und schließt die Listbox und trägt aria-haspopup="listbox", :aria-expanded="open" und :aria-controls="listboxId". Die Listbox selbst hat role="listbox", :aria-activedescendant="activeOptionId" und die entsprechende ID. Jede Option hat role="option", eine eindeutige ID, :aria-selected="isSelected(index)" und einen tabindex="-1".
Das aria-activedescendant-Attribut auf der Listbox ist der ARIA-Mechanismus, der Screen Readern mitteilt, welche Option gerade "aktiv" (fokussiert) ist, ohne den DOM-Fokus tatsächlich auf die Option zu verschieben. Das ermöglicht, den Fokus auf der Listbox zu halten und trotzdem die Navigation zwischen Optionen anzuzeigen. In einer einfacheren Implementierung kann man auch den Fokus direkt auf die Optionen verschieben – das erfordert dann kein aria-activedescendant, aber mehr Fokus-Management-Code.
// Vollständiges keyboard-navigierbares Dropdown
function keyboardDropdown(options = []) {
return {
open: false,
activeIndex: -1,
selectedIndex: -1,
options: options,
listboxId: `listbox-${Math.random().toString(36).slice(2, 7)}`,
typeAheadBuffer: '',
typeAheadTimer: null,
// Getter für ARIA-activedescendant
get activeOptionId() {
if (this.activeIndex < 0) return null;
return `${this.listboxId}-option-${this.activeIndex}`;
},
get selectedLabel() {
if (this.selectedIndex < 0) return 'Bitte wählen...';
return this.options[this.selectedIndex]?.label ?? 'Bitte wählen...';
},
toggle() {
this.open ? this.close() : this.openDropdown();
},
openDropdown() {
this.open = true;
// Aktive Option auf ausgewählte oder erste setzen
this.activeIndex = this.selectedIndex >= 0 ? this.selectedIndex : 0;
this.$nextTick(() => {
this.$el.querySelector(`[role="listbox"]`)?.focus();
this.scrollActiveIntoView();
});
},
close() {
this.open = false;
this.activeIndex = -1;
// Fokus zurück zum Trigger
this.$el.querySelector('button[aria-haspopup]')?.focus();
},
selectOption(index) {
this.selectedIndex = index;
this.$dispatch('vendor:option-selected', {
value: this.options[index]?.value,
label: this.options[index]?.label
});
this.close();
},
isSelected(index) {
return this.selectedIndex === index;
},
isActive(index) {
return this.activeIndex === index;
}
};
}
3. Keyboard-Handler: Pfeiltasten, Home, End, Escape
Der Keyboard-Handler ist das Herzstück des barrierefreien Dropdowns. Er sitzt auf dem role="listbox"-Element und fängt alle relevanten Tasten ab. Die Alpine.js-Syntax macht das lesbar: @keydown.arrow-down.prevent, @keydown.arrow-up.prevent, @keydown.home.prevent, @keydown.end.prevent, @keydown.enter.prevent, @keydown.space.prevent, @keydown.escape. Das .prevent-Modifier verhindert das Standard-Browser-Verhalten (Scrollen) für die Navigation-Tasten.
Wichtig ist das Wrapping-Verhalten bei Pfeiltasten: Wenn der Nutzer bei der letzten Option Pfeil-nach-unten drückt, soll zur ersten Option gesprungen werden – und umgekehrt. Das ist das Standard-WAI-ARIA-Pattern und erwartet von Screen-Reader-Nutzern. Alternativ kann man ohne Wrapping implementieren (letztes Element bleibt beim Pfeil-nach-unten) – beide Varianten sind spec-konform, aber Wrapping ist die üblichere Wahl für kurze Listen.
// Keyboard-Handler-Methoden (Teil der keyboardDropdown-Komponente)
handleKeydown(event) {
if (!this.open) {
// Dropdown geschlossen: Space, Enter, Pfeiltasten öffnen es
if (['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(event.key)) {
event.preventDefault();
this.openDropdown();
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.activeIndex = (this.activeIndex + 1) % this.options.length;
this.scrollActiveIntoView();
break;
case 'ArrowUp':
event.preventDefault();
this.activeIndex = this.activeIndex <= 0
? this.options.length - 1
: this.activeIndex - 1;
this.scrollActiveIntoView();
break;
case 'Home':
event.preventDefault();
this.activeIndex = 0;
this.scrollActiveIntoView();
break;
case 'End':
event.preventDefault();
this.activeIndex = this.options.length - 1;
this.scrollActiveIntoView();
break;
case 'Enter':
case ' ':
event.preventDefault();
if (this.activeIndex >= 0) {
this.selectOption(this.activeIndex);
}
break;
case 'Escape':
this.close();
break;
case 'Tab':
// Tab schließt das Dropdown — Fokus geht zum nächsten Element
this.open = false;
this.activeIndex = -1;
break;
default:
// Printable Characters → Type-Ahead
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
this.handleTypeAhead(event.key);
}
}
},
scrollActiveIntoView() {
this.$nextTick(() => {
const activeEl = this.$el.querySelector(`#${this.activeOptionId}`);
activeEl?.scrollIntoView({ block: 'nearest' });
});
}
4. Type-Ahead: Direktnavigation per Buchstabe
Type-Ahead ist das Keyboard-Pattern, das es Nutzern ermöglicht, durch Tippen von Buchstaben direkt zur passenden Option zu springen. Wenn ein Nutzer "S" tippt, springt der Fokus zur ersten Option, die mit "S" beginnt. Wenn er schnell "Sc" tippt, springt er zur ersten Option, die mit "Sc" beginnt. Wenn dieselbe Taste mehrfach gedrückt wird, werden alle passenden Optionen zyklisch durchlaufen.
Die Implementierung in Alpine.js nutzt einen Type-Ahead-Buffer mit einem Timeout: Gedrückte Buchstaben werden in einem String akkumuliert. Nach einer kurzen Pause (typischerweise 300–500ms) wird der Buffer geleert. Bei jedem neuen Zeichen wird im Buffer nach einer passenden Option gesucht. Das erfordert, dass die Optionslabels für den Vergleich normalisiert werden (toLowerCase, Entfernung von Diakritika).
// Type-Ahead-Implementierung
handleTypeAhead(char) {
// Buffer mit neuem Zeichen erweitern
this.typeAheadBuffer += char.toLowerCase();
// Vorherigen Timer zurücksetzen
clearTimeout(this.typeAheadTimer);
// Match in Optionen suchen
const match = this.findTypeAheadMatch(this.typeAheadBuffer);
if (match !== -1) {
this.activeIndex = match;
this.scrollActiveIntoView();
}
// Buffer nach 500ms leeren
this.typeAheadTimer = setTimeout(() => {
this.typeAheadBuffer = '';
}, 500);
},
findTypeAheadMatch(buffer) {
// Normalisierung für Vergleich (Kleinbuchstaben, Diakritika entfernen)
const normalize = (str) => str
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '');
const normalizedBuffer = normalize(buffer);
// Suche nach Übereinstimmung ab aktuellem Index (für zyklisches Durchlaufen)
const startFrom = buffer.length === 1 ? this.activeIndex + 1 : 0;
for (let i = 0; i < this.options.length; i++) {
const idx = (startFrom + i) % this.options.length;
const label = normalize(this.options[idx].label);
if (label.startsWith(normalizedBuffer)) {
return idx;
}
}
return -1; // Keine Übereinstimmung
},
5. ARIA-Attribute: die vollständige Implementierung
Die vollständige ARIA-Implementierung eines Listbox-Dropdowns erfordert Attribute auf drei Ebenen. Auf dem Trigger-Button: aria-haspopup="listbox" (kündigt an, dass ein Listbox-Popup folgt), :aria-expanded="open" (aktueller Zustand) und :aria-controls="listboxId" (ID der Listbox). Auf der Listbox selbst: role="listbox", :id="listboxId", :aria-activedescendant="activeOptionId" (aktive Option) und tabindex="0" (fokussierbar). Auf jeder Option: role="option", eindeutige :id, :aria-selected="isSelected(index)".
Ein häufiger Fehler: aria-selected wird nur auf die ausgewählte Option gesetzt und auf allen anderen weggelassen. Der korrekte Standard: aria-selected="false" auf allen nicht ausgewählten Optionen – nicht einfach das Attribut weglassen. Screenreader verhalten sich bei fehlendem aria-selected unterschiedlich; explizites false ist zuverlässiger. In Alpine.js: :aria-selected="isSelected(index) ? 'true' : 'false'".
6. Fokus-Management und Scroll-into-View
Wenn eine Listbox lang ist und nicht alle Optionen gleichzeitig sichtbar sind, muss die aktive Option automatisch ins Sichtfeld gescrollt werden. element.scrollIntoView({ block: 'nearest' }) ist die korrekte Lösung: block: 'nearest' scrollt das Element nur so weit, dass es gerade sichtbar ist – ohne unnötig viel zu scrollen. Das ist das natürliche Verhalten, das Nutzer von nativen Select-Elementen kennen.
Das Fokus-Management im Keyboard-Dropdown hat eine wichtige Nuance: Bei der aria-activedescendant-Strategie bleibt der DOM-Fokus auf dem role="listbox"-Element. Screen Reader lesen die aktive Option vor, weil aria-activedescendant auf die Option-ID zeigt. Beim Schließen des Dropdowns (Escape oder Auswahl) muss der Fokus zurück zum Trigger-Button. Das stellt sicher, dass Tastaturnutzer nach einer Auswahl nicht ihren Platz auf der Seite verlieren.
7. Mehrspaltige und gruppierte Dropdowns
Manche Dropdowns zeigen Optionen in Gruppen (z.B. nach Kategorie) oder in mehreren Spalten. Die ARIA-Struktur für Gruppen nutzt role="group" mit aria-label innerhalb der Listbox. Die Optionen innerhalb einer Gruppe sind weiterhin role="option". Die Keyboard-Navigation durchläuft alle Optionen in Dokumentreihenfolge, unabhängig von der visuellen Spaltenanordnung – das ist der Standard und erwartet.
Für mehrspaltige Layouts ist die Alpine.js-Implementierung identisch zur einspaltigen – nur das CSS ändert sich. Die Logik für activeIndex, Keyboard-Handler und ARIA-Attribute bleibt gleich. Das ist ein Vorteil des Component-Designs: Die Darstellung (Spalten im CSS-Grid) ist vollständig getrennt von der Verhaltenlogik (Keyboard-Navigation in der Alpine.js-Komponente).
// Gruppiertes Dropdown mit Kategorien
function groupedDropdown() {
return {
open: false,
activeIndex: -1,
selectedIndex: -1,
listboxId: `grouped-listbox-${Math.random().toString(36).slice(2, 7)}`,
// Flache Liste aller Optionen (für einfache Index-Navigation)
groups: [
{
label: 'Deutschland',
options: [
{ value: 'de-ber', label: 'Berlin' },
{ value: 'de-ham', label: 'Hamburg' },
{ value: 'de-muc', label: 'München' }
]
},
{
label: 'Österreich',
options: [
{ value: 'at-vie', label: 'Wien' },
{ value: 'at-gra', label: 'Graz' }
]
}
],
// Flache Optionsliste für Navigation
get flatOptions() {
return this.groups.flatMap(g => g.options);
},
get activeOptionId() {
return this.activeIndex >= 0
? `${this.listboxId}-option-${this.activeIndex}`
: null;
},
get selectedLabel() {
if (this.selectedIndex < 0) return 'Stadt wählen...';
return this.flatOptions[this.selectedIndex]?.label;
},
// Index in flacher Liste finden für ein Option-Objekt
flatIndex(groupIndex, optionIndex) {
let flat = 0;
for (let g = 0; g < groupIndex; g++) {
flat += this.groups[g].options.length;
}
return flat + optionIndex;
},
selectByFlatIndex(flatIndex) {
this.selectedIndex = flatIndex;
this.close();
this.$dispatch('vendor:city-selected', {
value: this.flatOptions[flatIndex]?.value
});
}
};
}
8. Combobox: Suche + Dropdown kombiniert
Eine Combobox kombiniert ein Text-Input (für Freitext-Suche) mit einer Listbox (für Optionen-Auswahl). Das ist das komplexeste ARIA-Pattern und das häufigste in E-Commerce-Projekten: Produktsuche, Adress-Autovervollständigung, Kategorie-Filterung. WAI-ARIA definiert für Comboboxes ein eigenes Keyboard-Pattern, das sich von der einfachen Listbox unterscheidet.
Im Combobox-Pattern liegt der Fokus auf dem Input-Element, nicht auf der Listbox. Pfeiltaste nach unten öffnet die Listbox und bewegt den Fokus auf die erste Option (oder via aria-activedescendant auf die erste Option, ohne DOM-Fokus zu verschieben). Das Input bleibt fokussiert; der Nutzer kann weiter tippen. Enter wählt die fokussierte Option aus. Alpine.js implementiert das mit der Trennung von inputFocused und activeIndex.
| Taste | Listbox-Verhalten | Combobox-Verhalten | Alpine.js-Handler |
|---|---|---|---|
| Pfeil ↓ | Nächste Option (wrapping) | Öffnet / nächste Option | @keydown.arrow-down.prevent |
| Pfeil ↑ | Vorherige Option (wrapping) | Vorherige Option | @keydown.arrow-up.prevent |
| Home | Erste Option | Erste Option | @keydown.home.prevent |
| End | Letzte Option | Letzte Option | @keydown.end.prevent |
| Escape | Schließen, Fokus zum Trigger | Schließen, Input-Inhalt zurücksetzen | @keydown.escape |
Mironsoft
Alpine.js, ARIA und barrierefreie Hyvä-Komponenten
Barrierefreie UI-Komponenten für dein Hyvä-Projekt?
Wir entwickeln vollständig keyboard-navigierbare Dropdowns, Comboboxes und Listboxes nach WAI-ARIA-Standard – für Hyvä-Themes und Magento 2-Projekte.
UI-Komponenten
Listboxes, Comboboxes, Menüs und Tabs nach WAI-ARIA-Standard
WCAG 2.2 AA
Vollständige Keyboard-Navigation und Screen-Reader-Unterstützung
Hyvä-Integration
CSP-konforme Alpine.js-Komponenten für alle Hyvä-Theme-Seiten
10. Zusammenfassung
Ein vollständig keyboard-navigierbares Dropdown in Alpine.js implementiert das WAI-ARIA Listbox-Pattern: Pfeiltasten für Navigation, Home/End für erste/letzte Option, Enter/Space für Auswahl, Escape für Schließen, und Type-Ahead für Buchstaben-Navigation. Die Alpine.js-Implementierung besteht aus einer benannten Komponentenfunktion mit State (open, activeIndex, selectedIndex), Keyboard-Handler-Methoden und reaktiven ARIA-Attribut-Bindings.
Type-Ahead mit Buffer und Timeout ist das technisch anspruchsvollste Element, aber für Screen-Reader-Nutzer besonders wichtig bei langen Optionslisten. scrollIntoView({ block: 'nearest' }) sorgt dafür, dass die aktive Option immer sichtbar ist. Das Fokus-Management beim Schließen (zurück zum Trigger) ist die dritte kritische Komponente. Zusammen ergeben diese drei Elemente ein Dropdown, das für alle Nutzer – mit und ohne Maus – vollständig bedienbar ist.
Keyboard-Dropdown mit Alpine.js — Das Wichtigste auf einen Blick
WAI-ARIA Listbox-Pattern
Pfeiltasten ↑↓ navigieren. Home/End zu erster/letzter. Enter/Space wählt aus. Escape schließt. Printable Characters → Type-Ahead.
ARIA-Attribute
Trigger: aria-haspopup, aria-expanded, aria-controls. Listbox: role, aria-activedescendant. Optionen: role, id, aria-selected.
Type-Ahead
Buffer + 500ms-Timer. Normalisierung (toLowerCase, Diakritika). Zyklische Suche ab aktuellem Index. Mehrfaches Drücken durchläuft Matches.
Fokus-Management
Beim Öffnen: Fokus auf Listbox, activeIndex auf Auswahl oder 0. Beim Schließen: Fokus zurück zum Trigger. scrollIntoView für aktive Option.