x-data
Alpine
Alpine.js · Keyboard-Navigation · WAI-ARIA · Listbox · Accessibility
Keyboard-navigierbares
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.

19 Min. Lesezeit Pfeiltasten · Home · End · Escape · Type-Ahead · ARIA WAI-ARIA 1.2 · Alpine.js 3.x · WCAG 2.2 AA

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.

11. FAQ: Keyboard-navigierbares Dropdown mit Alpine.js

1Listbox vs. Combobox im WAI-ARIA-Standard?
Listbox: nur vordefinierte Optionen. Combobox: Text-Input + Listbox, erlaubt Freitext. Keyboard-Pattern unterscheidet sich in der Fokussteuerung.
2Was ist aria-activedescendant?
Zeigt Screen Readern die aktive Option ohne DOM-Fokus wirklich zu verschieben. Fokus bleibt auf Listbox, ARIA-Feedback trotzdem für aktive Option.
3aria-selected='false' oder weglassen?
Immer explizit aria-selected='false'. Fehlende Attribute werden von Screen Readern unterschiedlich behandelt. Explizit false ist zuverlässiger.
4Type-Ahead in Alpine.js implementieren?
Buffer akkumulieren, 500ms-Timer für Reset. Label normalisieren (toLowerCase, Diakritika). Zyklische Suche ab aktuellem Index mit startsWith.
5Wrapping bei Pfeiltasten — ja oder nein?
Beide WAI-ARIA-konform. Wrapping üblicher und intuitiver bei kurzen Listen. Kein Wrapping sinnvoll bei sehr langen Listen. Je nach Anwendungsfall entscheiden.
6Warum .prevent bei Pfeiltasten?
Ohne .prevent scrollt der Browser beim Pfeil-Drücken die Seite. Das verhindert Navigation in der Listbox. @keydown.arrow-down.prevent blockiert Standard-Scroll.
7scrollIntoView für aktive Option?
element.scrollIntoView({ block: 'nearest' }) scrollt nur soweit wie nötig. In $nextTick aufrufen damit Alpine.js ID aktualisiert hat bevor querySelector sucht.
8Mehrere Dropdown-Instanzen auf einer Seite?
Zufällige listboxId pro Instanz (Math.random().toString(36)). Damit zeigen aria-controls und aria-activedescendant auf die richtige Listbox.
9Tab im Dropdown — was passiert?
Tab schließt Dropdown und lässt Fokus zum nächsten Tab-Stopp weitergehen. Kein Wrapping, kein Abfangen. Nur Escape gibt explizit Fokus zurück zum Trigger.
10Keyboard-Navigation ohne Screen Reader testen?
Nur Tastatur: Tab zum Trigger, Enter/Space öffnen, Pfeiltasten navigieren, Buchstaben Type-Ahead, Escape schließt. document.activeElement in Console prüft Fokus-Position.