x-data
Alpine
Alpine.js · ARIA · Barrierefreiheit · Combobox
Alpine.js Autocomplete Combobox
Suchvorschläge barrierefrei umsetzen

Eine Autocomplete-Combobox klingt einfach – bis man ARIA-Rollen, Tastaturnavigation, Screenreader-Ankündigungen und Focus-Management zusammenbringt. Mit Alpine.js lässt sich das alles deklarativ und ohne eine Zeile jQuery lösen.

12 Min. Lesezeit x-data · x-show · @keydown · ARIA · WCAG 2.1 Alpine.js 3.x · Tailwind CSS · Hyvä

1. Warum native Autocomplete nicht ausreicht

Das HTML-Attribut autocomplete mit einem <datalist>-Element ist der native Weg für Suchvorschläge – aber er deckt kaum einen realen Anwendungsfall vollständig ab. Das Erscheinungsbild des Suggestion-Dropdowns ist nicht gestaltbar, die Filterlogik ist auf einfaches Prefix-Matching beschränkt, und Accessibility-Attribute wie aria-activedescendant oder aria-expanded fehlen komplett. Sobald die Designvorgaben über ein schlichtes Betriebssystem-Dropdown hinausgehen oder die Filterlogik fuzzy Matching, Highlighting oder Remote-Daten erfordert, ist eine eigene Implementierung unvermeidbar.

Die häufige Reaktion auf diese Situation ist der Griff zu einer vollständigen Komponentenbibliothek wie Select2 oder Choices.js. Beide bringen jedoch jQuery oder eigene Event-Systeme mit, die auf einer Hyvä-Seite, die Alpine.js nutzt, redundant und problematisch sind. Mit Alpine.js lässt sich eine vollständige, barrierefreie Autocomplete-Combobox in unter 80 Zeilen HTML und JavaScript-Logik innerhalb von x-data realisieren – ohne eine einzige externe Abhängigkeit. Der Schlüssel liegt im Verständnis der ARIA-Combobox-Pattern, die von der W3C WAI-ARIA Authoring Practices definiert werden.

2. ARIA-Rollen für Comboboxen verstehen

Die ARIA-Spezifikation definiert das Combobox-Pattern als Kombination aus einem Texteingabefeld mit role="combobox" und einer assoziierten Listbox mit role="listbox". Das Eingabefeld trägt die Attribute aria-expanded (true/false je nach Sichtbarkeit des Dropdowns), aria-haspopup="listbox" (kündigt Screenreadern an, dass eine Listbox folgt) und aria-autocomplete="list" (beschreibt den Filtertyp). Das Attribut aria-activedescendant zeigt auf die ID des aktuell hervorgehobenen Listeneintrags – so kann der Screenreader den fokussierten Eintrag ansagen, ohne dass der Tastaturfokus das Eingabefeld verlässt.

Jeder Listeneintrag bekommt role="option" und eine eindeutige ID. Das Attribut aria-selected="true" markiert den ausgewählten Eintrag. Diese Attributkombination ist nicht optional – ohne sie ist eine Combobox für Tastaturnutzer und Screenreader-Nutzer praktisch nicht bedienbar und scheitert an einer WCAG-2.1-Prüfung nach Erfolgskriterium 4.1.2. Mit Alpine.js lassen sich alle diese Attribute direkt an den reaktiven State binden: :aria-expanded="open", :aria-activedescendant="activeId" und :aria-selected="item.id === selectedId" aktualisieren sich automatisch bei jeder State-Änderung.


// Alpine.js Combobox — full ARIA-compliant state
function combobox(items) {
  return {
    query: '',
    open: false,
    activeIndex: -1,
    selectedValue: null,

    // Filtered list derived from query
    get filtered() {
      const q = this.query.toLowerCase().trim();
      if (!q) return items.slice(0, 8);
      return items.filter(i =>
        i.label.toLowerCase().includes(q) ||
        i.keywords?.some(k => k.toLowerCase().includes(q))
      ).slice(0, 10);
    },

    // ID of currently highlighted option for aria-activedescendant
    get activeId() {
      if (this.activeIndex < 0 || !this.filtered[this.activeIndex]) return '';
      return `option-${this.filtered[this.activeIndex].id}`;
    },

    openList()  { this.open = true; this.activeIndex = -1; },
    closeList() { this.open = false; this.activeIndex = -1; },

    select(item) {
      this.selectedValue = item;
      this.query = item.label;
      this.closeList();
      this.$refs.input.focus();
    }
  };
}

3. Datenstruktur und Alpine-State aufbauen

Die sauberste Variante ist eine Funktion, die den State als Objekt zurückgibt und in x-data aufgerufen wird. Das vermeidet verschachtelte Objekte in einem einzigen x-data-Literal und erlaubt es, denselben State für mehrere Comboboxen auf einer Seite wiederzuverwenden. Die Iterliste sollte bereits beim Seitenaufruf als JSON-Array im Template vorhanden sein, damit kein sichtbares Laden-Flackern entsteht. Für Remote-Daten wird die Liste initial leer geladen und via fetch befüllt – dazu mehr in Abschnitt 7.

Wichtig für Performance: Die gefilterte Liste wird als get filtered() als Getter definiert, nicht als reaktive Variable. Alpine.js cached Getter-Ergebnisse nicht automatisch, aber da die Filterlogik auf this.query reagiert und nur bei Eingabe neu berechnet wird, ist das in der Praxis kein Problem für Listen bis etwa 5.000 Einträge. Für größere Datenmengen empfiehlt sich eine debounced Fetch-Anfrage statt clientseitigem Filtern.

4. Live-Filterlogik ohne Debounce-Bibliothek

Alpine.js bringt kein eingebautes Debounce mit – aber das .debounce-Modifier für @input und @keyup ist seit Alpine 3.x als Modifier verfügbar: @input.debounce.300ms="onInput" verzögert den Aufruf automatisch um 300 Millisekunden nach der letzten Eingabe. Für rein clientseitige Filterung ist das nicht nötig, weil die Berechnung synchron im selben Tick stattfindet und keine Netzwerkanfrage auslöst. Für API-Anfragen ist der .debounce-Modifier aber zwingend, um nicht bei jedem Tastendruck einen Request abzusetzen.

Das Highlighting von Treffern im Ergebnistext erfordert etwas mehr Aufwand, weil Alpine.js keine Template-Funktion für innerHTML kennt. Die Lösung: Eine Hilfsfunktion highlight(text, query) gibt einen String mit <mark>-Tags zurück, der dann via x-html in den Eintrag geschrieben wird. Wichtig dabei: Benutzereingaben müssen vor der Injection mit text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') escaped werden, damit ein regulärer Ausdruck daraus gebildet werden kann, ohne SQL-Injection-artige Pattern-Fehler zu erzeugen.


// Highlight matching text safely — prevents regex injection
function highlight(text, query) {
  if (!query.trim()) return text;
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const regex = new RegExp(`(${escaped})`, 'gi');
  return text.replace(regex, '<mark class="bg-teal-100 text-teal-900 rounded px-0.5">$1</mark>');
}

// Debounced fetch for remote suggestions (Alpine @input.debounce.300ms)
async function fetchSuggestions(query) {
  if (query.length < 2) { this.items = []; return; }
  this.loading = true;
  try {
    const res = await fetch(`/api/suggestions?q=${encodeURIComponent(query)}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    this.items = await res.json();
  } catch (e) {
    console.error('Suggestion fetch failed:', e);
    this.items = [];
  } finally {
    this.loading = false;
  }
}

5. Tastaturnavigation: Arrow, Enter, Escape

Die Tastaturnavigation ist der aufwändigste Teil einer barrierefreien Combobox. Nach ARIA Authoring Practices müssen folgende Tastenkombinationen funktionieren: ArrowDown öffnet die Liste und bewegt den Fokus zum nächsten Eintrag, ArrowUp bewegt ihn zum vorherigen, Enter übernimmt den aktiven Eintrag und schließt die Liste, Escape schließt die Liste ohne Auswahl, Home und End springen zum ersten bzw. letzten Eintrag. Dabei verlässt der echte DOM-Fokus das Eingabefeld nie – nur aria-activedescendant ändert sich, und der Screenreader liest den hervorgehobenen Eintrag vor.

Mit Alpine.js wird das über @keydown.prevent an der Eingabe gelöst. Das .prevent verhindert, dass ArrowDown und ArrowUp den Scrollbereich der Seite verschieben, wenn die Liste sichtbar ist. Das Wrapping am Ende der Liste – von letztem Eintrag zurück zum ersten – verbessert die Bedienbarkeit deutlich, ist aber nach ARIA optional. In der Praxis empfiehlt sich das Wrapping, weil Nutzer es von Menüs kennen. Die Scroll-ins-Sichtbare-Logik für aria-activedescendant erledigt this.$refs.list.children[this.activeIndex]?.scrollIntoView({ block: 'nearest' }).


// Keyboard handler — attach via @keydown="handleKey($event)" on input
handleKey(e) {
  const len = this.filtered.length;
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      if (!this.open) { this.open = true; return; }
      this.activeIndex = (this.activeIndex + 1) % len;
      this.scrollActive();
      break;
    case 'ArrowUp':
      e.preventDefault();
      if (!this.open) return;
      this.activeIndex = (this.activeIndex - 1 + len) % len;
      this.scrollActive();
      break;
    case 'Enter':
      e.preventDefault();
      if (this.open && this.activeIndex >= 0) {
        this.select(this.filtered[this.activeIndex]);
      }
      break;
    case 'Escape':
      this.closeList();
      break;
    case 'Home':
      if (this.open) { e.preventDefault(); this.activeIndex = 0; this.scrollActive(); }
      break;
    case 'End':
      if (this.open) { e.preventDefault(); this.activeIndex = len - 1; this.scrollActive(); }
      break;
  }
},
scrollActive() {
  this.$nextTick(() => {
    this.$refs.list?.children[this.activeIndex]?.scrollIntoView({ block: 'nearest' });
  });
}

6. Focus-Management und Klick-außen schließen

Eine Combobox, die beim Klicken außerhalb nicht schließt, ist in der Praxis unbrauchbar. Alpine.js bietet dafür den Magic Modifier @click.outside, der auf das Root-Element des x-data-Blocks angewendet wird: @click.outside="closeList()". Das ist sauberer als ein globaler document.addEventListener('click', ...), weil Alpine das Listener-Cleanup beim Teardown des Components automatisch übernimmt und keine Memory-Leaks entstehen.

Beim Schließen per Tab-Taste – wenn der Nutzer die Combobox verlässt ohne Enter zu drücken – muss die Liste ebenfalls geschlossen werden. Das erledigt @blur.capture="closeList()" nicht zuverlässig, weil der Blur-Event auch beim Klicken auf einen Listeneintrag feuert, bevor der Click-Event ankommt. Die Lösung ist eine kurze Verzögerung: closeList() wird mit setTimeout(() => { if (!this.$el.contains(document.activeElement)) this.closeList(); }, 100) aufgerufen. Alpine's $nextTick ist hier zu kurz – 100ms geben dem Click-Event genug Zeit, zuerst zu feuern.

7. Asynchrone Vorschläge von einer API laden

Für serverseitige Suchvorschläge – etwa Produktsuche in einem Magento-Shop – wird die Filterung vom Client auf den Server verlagert. Die Alpine-Komponente hält dann nur die aktuell geladenen Vorschläge als reaktive Liste und feuert bei Eingabe einen fetch-Request. Der @input.debounce.300ms-Modifier stellt sicher, dass erst nach 300ms Tippstille eine Anfrage gesendet wird. Das reduziert die Server-Last bei schnellem Tippen erheblich. Gleichzeitig muss die Komponente veraltete Antworten ignorieren: Wenn der Nutzer "Alpine" tippt und die Antwort für "Alp" nach der Antwort für "Alpine" eintrifft, dürfen die älteren Ergebnisse nicht angezeigt werden.

Die einfachste Lösung für das Race-Condition-Problem ist ein Request-Counter: Jede Anfrage bekommt eine aufsteigende ID, und in der then-Callback wird geprüft, ob die ID noch der aktuellen entspricht. Eine sauberere Alternative ist AbortController: Der vorherige Request wird vor jedem neuen Aufruf abgebrochen. Alpine's reaktives System kümmert sich um das Re-Rendering, sobald this.items gesetzt wird.

8. Gruppierte Ergebnislisten mit Sections

Wenn Suchvorschläge aus verschiedenen Kategorien kommen – etwa Produkte, Kategorien und CMS-Seiten –, hilft eine gruppierte Darstellung. Die ARIA-Spezifikation unterstützt das mit role="group" und aria-label innerhalb der Listbox. Jede Gruppe bekommt eine Überschrift mit role="presentation" (damit Screenreader sie nicht als interaktives Element behandeln) und darunter die zugehörigen role="option"-Einträge.

In Alpine.js wird die gefilterte Liste dafür als Objekt mit Gruppen-Keys strukturiert: { products: [...], categories: [...], pages: [...] }. Im Template iteriert x-for erst über die Gruppen, dann innerhalb jeder Gruppe über die Einträge. Der activeIndex muss dabei die flache Position in der Gesamtliste widerspiegeln, nicht die Position innerhalb der Gruppe. Eine Hilfsfunktion flatIndex(group, indexInGroup) berechnet das – sie summiert die Längen aller vorherigen Gruppen und addiert die Position innerhalb der aktuellen Gruppe.

9. Alpine Combobox vs. externe Bibliotheken

Der häufigste Einwand gegen eine eigene Implementierung ist der Entwicklungsaufwand. In der Praxis zeigt sich jedoch, dass eine vollständige Alpine.js-Combobox mit Tastaturnavigation, ARIA und Remote-Loading in etwa 100 Zeilen realisierbar ist – verglichen mit dem Einbinden, Konfigurieren und Stylen einer externen Bibliothek oft sogar schneller. Der entscheidende Vorteil: Null zusätzliche JavaScript-Bytes im Bundle, keine Konflikte mit dem Alpine-Lifecycle, volle Kontrolle über Markup und ARIA-Attribute.

Kriterium Select2 / Choices.js Alpine.js nativ Vorteil Alpine
Bundle-Größe ~70–100 KB (+ jQuery) 0 KB extra Alpine.js ohnehin geladen
ARIA-Compliance Teilweise, oft veraltet Vollständig kontrollierbar Jedes Attribut gezielt setzbar
Tailwind-Styling Eigene CSS-Klassen, Konflikte Natives Markup, volle Kontrolle Kein Style-Override nötig
Remote-Loading Eingebaut, konfigurierbar fetch + Alpine, flexibel Eigene Auth-Header, AbortController
Hyvä-Kompatibilität Problematisch (jQuery-Abhängigkeit) Nativ, keine Konflikte Kein CSP-Problem

Die einzige sinnvolle Ausnahme für eine externe Bibliothek ist Headless UI oder Radix (React-basiert) – aber die sind für Alpine.js-Projekte nicht anwendbar. Wer auf Hyvä setzt, hat Alpine.js als Standard und braucht keine weiteren UI-Frameworks. Die hier beschriebene Implementierung ist produktionsreif, testbar und lässt sich mit einem einfachen x-data="combobox(items)" auf jeder Seite einbinden.

Mironsoft

Alpine.js Komponenten · Hyvä Theme Entwicklung · Barrierefreiheit

Barrierefreie Komponenten für euren Magento-Shop?

Wir entwickeln Alpine.js-Komponenten, die WCAG 2.1 AA erfüllen, sich nahtlos in Hyvä integrieren und keine zusätzlichen JavaScript-Abhängigkeiten mitbringen.

Barrierefreiheits-Audit

ARIA-Prüfung und WCAG-2.1-Analyse für bestehende Alpine-Komponenten

Komponentenentwicklung

Autocomplete, Modal, Tabs, Accordion – alles Alpine-nativ, kein jQuery

Hyvä-Integration

Nahtlose Einbindung in bestehende Hyvä-Themes ohne Layout-Konflikte

10. Zusammenfassung

Eine barrierefreie Autocomplete-Combobox mit Alpine.js erfordert konsequente Arbeit an drei Fronten: dem reaktiven State mit korrekten ARIA-Attributen, der vollständigen Tastaturnavigation nach WAI-ARIA-Authoring-Practices und dem Focus-Management beim Öffnen und Schließen. All das ist mit Alpine.js ohne externe Bibliotheken realisierbar und integriert sich nahtlos in Hyvä-Themes und Tailwind CSS. Die investierte Zeit zahlt sich aus: Eine sauber implementierte Combobox funktioniert für Maus-, Tastatur- und Screenreader-Nutzer gleichermaßen gut und erfüllt WCAG 2.1 AA.

Der wichtigste Takeaway: ARIA-Attribute sind keine optionalen Extras, sondern funktionale Schnittstellen für Hilfstechnologien. aria-activedescendant, aria-expanded und role="option" sind so wichtig wie die visuelle Hervorhebung des aktiven Eintrags. Wer Alpine.js einsetzt und diese Attribute an den reaktiven State bindet, bekommt Barrierefreiheit fast geschenkt – weil Alpine's reaktives System die Attribute bei jeder State-Änderung automatisch aktualisiert.

Alpine.js Autocomplete Combobox — Das Wichtigste auf einen Blick

ARIA-Pflichtattribute

role="combobox", aria-expanded, aria-haspopup="listbox", aria-activedescendant – alle via :-Binding an Alpine-State gebunden.

Tastaturnavigation

ArrowDown/Up, Enter, Escape, Home, End – über @keydown.prevent am Eingabefeld. Fokus verlässt das Input nie.

Klick-außen & Blur

@click.outside="closeList()" am Root-Element. Blur mit 100ms setTimeout statt @blur, um Click-Events nicht zu unterbrechen.

Remote-Loading

@input.debounce.300ms + AbortController verhindert veraltete Antworten. Race Conditions durch Request-Counter oder Abort eliminieren.

11. FAQ: Alpine.js Autocomplete Combobox

1Warum reicht datalist nicht aus?
Nicht gestaltbar, kein Highlighting, kein Remote-Loading, keine vollständigen ARIA-Attribute. Für produktionsreife Shops ist Alpine.js die bessere Wahl.
2Welche ARIA-Attribute sind Pflicht?
role="combobox", aria-expanded, aria-haspopup="listbox", aria-autocomplete="list" am Input; role="listbox" an der Liste; role="option" an jedem Eintrag.
3Race Conditions bei async Vorschlägen verhindern?
AbortController bricht den vorherigen Fetch ab. Alternativ ein Request-Counter – nur die Antwort der aktuellsten Anfrage wird übernommen.
4Warum @click.outside statt document-Listener?
Alpine entfernt den Listener automatisch beim Teardown. Kein Memory-Leak, kein manuelles removeEventListener nötig.
5Highlighting im Ergebnistext implementieren?
highlight(text, query) escaped die Eingabe als Regex und ersetzt Treffer mit mark-Tags. Ergebnis via x-html einfügen.
6Gruppen-Überschriften in der Ergebnisliste?
Gefilterte Liste als Objekt mit Gruppen-Keys, im Template x-for über Gruppen und Einträge. Überschriften mit role="presentation".
7aria-selected vs. aria-checked?
aria-selected gehört zu role="option" in einer Listbox. aria-checked gehört zu role="checkbox". Falsche Rolle führt zu falschen Screenreader-Ankündigungen.
8Keine Ergebnisse gefunden – was anzeigen?
Eintrag mit role="option" und aria-disabled="true" mit dem Text "Keine Ergebnisse". Alternativ aria-live="polite" für eine Screenreader-Ankündigung.
9In Magento-Formular einsetzen?
Verborgenes Input mit x-model an selectedValue.id. Das sichtbare Textfeld dient nur der Eingabe, der Submit-Wert liegt im versteckten Feld.
10ARIA-Compliance ohne Screenreader testen?
Chrome DevTools Accessibility-Tree, axe DevTools Extension für automatische WCAG-Prüfungen, NVDA oder VoiceOver für manuelle Tests.