Hyvä Store-Switcher & Währungsumschalter: Alpine-Dropdown Tutorial
AI generated
Hyvä
phtml
Hyvä Themes · Alpine.js · Magento 2 · Tailwind CSS
Store-Switcher und Währungsumschalter im Hyvä-Header
ein barrierefreies Alpine-Dropdown statt Luma UI-Components

Der Store-Switcher und der Währungsumschalter im Header sind keine gewöhnlichen Dropdowns: Beide müssen einen echten Formular-Submit an einen Magento-Controller auslösen, damit Cookie und Session serverseitig aktualisiert werden und den Seiten-Reload überstehen. Dieser Artikel zeigt, wie man beide Komponenten als zugängliches Alpine.js-Dropdown im Hyvä-Header baut, inklusive ARIA-Listbox-Pattern, Tastatursteuerung, Gruppierung nach Website bei vielen Store Views und CSP-konformer Inline-Registrierung, ganz ohne zusätzliches JavaScript-Bundle.

15 Min. Lesezeit Alpine.js · x-data · ARIA Listbox · Formular-Submit Magento 2.4.8-p4 · Hyvä Themes · Tailwind CSS v4

1. Warum diese beiden Header-Dropdowns eine eigene Betrachtung verdienen

Generische Alpine-Tutorials behandeln meist Mobile-Navigation, Modal-Dialoge oder die Ersetzung von Luma UI-Components durch eigene Komponenten-Architekturen. Der Store-Switcher und der Währungsumschalter im Header fallen aus diesem Muster heraus, weil beide keine rein clientseitigen Widgets sind. Ein Klick auf einen Eintrag in diesem Dropdown muss einen echten Request an einen Magento-Controller auslösen, der eine Session-Variable setzt und einen Cookie schreibt, ohne diesen Server-Roundtrip bleibt die Auswahl nur oberflächlicher UI-Zustand und geht beim nächsten Seitenaufruf verloren.

Genau an dieser Stelle scheitern viele naive Alpine-Implementierungen, die beide Komponenten wie ein gewöhnliches Dropdown-Menü behandeln: x-model auf eine lokale Variable, fertig. Das funktioniert optisch, hat aber keinerlei Wirkung auf Store-Kontext oder Preisanzeige. Die folgenden Abschnitte zeigen das vollständige Bild: das Backend-Datenmodell, den zwingenden Formular-Submit, die Alpine-Struktur für Trigger und Listbox, das ARIA-Pattern für Barrierefreiheit und die Besonderheiten der zweiten Komponente mit ihrem eigenen Controller.

2. Das Magento-Datenmodell im Hintergrund

Bevor im Template auch nur eine Zeile Alpine-Code steht, muss klar sein, woher die Daten für den Store-Switcher kommen. Die Liste der sichtbaren Store Views liefert \Magento\Store\Model\Store::getAvailableStoreViews() beziehungsweise, im ViewModel bevorzugt, StoreManagerInterface::getStores() gefiltert auf isActive() und das aktuelle Website-Scope. Jede Store View implementiert \Magento\Store\Api\Data\StoreInterface und liefert Code, Name, Website-Zuordnung und die Basis-URL, die für den Wechsel des Kontexts benötigt wird. Für ein sauberes ViewModel bündelt man diese Daten in ein einfaches Array aus Code, Label und Website-Name, das anschließend als JSON in ein data-*-Attribut serialisiert wird.

Der Currency-Dropdown nutzt ein eigenes, aber strukturell ähnliches Modell: \Magento\Directory\Model\Currency liefert über getConfigAllowCurrencies() die im Store aktivierten Währungscodes, während StoreInterface::getAvailableCurrencyCodes() die für den aktuellen Store-Scope zulässige Teilmenge zurückgibt. Für jeden Code lässt sich mit der PHP-Intl-Erweiterung oder Magentos CurrencyInterface ein Symbol und ein lokalisierter Name auflösen. Wichtig: Beide Datenquellen, Store Views wie Währungen, werden im ViewModel vorbereitet und nicht im Alpine-Code nachgeladen, Alpine bekommt nur die fertige Liste als JSON, keine eigene API-Anbindung.

3. Warum ein echter Formular-Submit zwingend ist

Der eigentliche Wechsel läuft in Magento über \Magento\Store\Controller\Store\SwitchAction. Dieser Controller erwartet zwei Request-Parameter: ___store mit dem Ziel-Store-Code und ___from_store mit dem Code der Ausgangs-Store-View. Der Controller validiert, ob der Zielstore erreichbar ist, setzt den Store-Cookie und leitet anschließend auf eine passende URL im neuen Store-Kontext um. Ein Store-Switcher, der diesen Request nicht auslöst, ändert nichts an Preisen, Übersetzungen oder Store-spezifischen CMS-Inhalten, egal wie überzeugend das UI aussieht.

Deshalb bleibt im Alpine-Dropdown ein klassisches HTML-<form> mit method="post" erhalten, das per JavaScript programmatisch abgeschickt wird, sobald eine Option ausgewählt wurde. Alpine übernimmt ausschließlich die Präsentation: Dropdown öffnen und schließen, aktuelle Auswahl anzeigen, verstecktes Formularfeld befüllen. Der folgende Ausschnitt zeigt das vollständige phtml-Template mit Formular, Trigger-Button und Listbox:


<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Store\Model\Store $currentStore */
?>
<div class="relative" x-data="storeSwitcher()" @click.outside="open = false" @keydown.escape.window="open = false">
  <form id="store-switcher-form" action="<?= $block->escapeUrl($block->getSwitchUrl()) ?>" method="post">
    <input type="hidden" name="___store" x-bind:value="selectedStoreCode">
    <input type="hidden" name="___from_store" value="<?= $block->escapeHtmlAttr($currentStore->getCode()) ?>">
    <input type="hidden" name="uenc" value="<?= $block->escapeHtmlAttr($block->getCurrentBase64Url()) ?>">
  </form>

  <button
    type="button"
    @click="open = !open"
    :aria-expanded="open.toString()"
    aria-haspopup="listbox"
    aria-controls="store-switcher-listbox"
    class="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50"
  >
    <span x-text="selectedStoreLabel"></span>
    <svg class="h-4 w-4" :class="{ 'rotate-180': open }" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
  </button>

  <ul
    x-show="open"
    x-cloak
    id="store-switcher-listbox"
    role="listbox"
    aria-label="Store view"
    class="absolute z-20 mt-2 max-h-72 w-64 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
  >
    <template x-for="storeView in storeViews" :key="storeView.code">
      <li
        role="option"
        :aria-selected="storeView.code === selectedStoreCode"
        @click="selectStore(storeView)"
        @keydown.enter="selectStore(storeView)"
        tabindex="0"
        class="cursor-pointer px-4 py-2 text-sm hover:bg-orange-50"
        x-text="storeView.name"
      ></li>
    </template>
  </ul>
</div>

4. Alpine-Grundgerüst: x-data, open-State und Trigger-Button

Die Alpine-Komponente hinter diesem Dropdown ist bewusst schlank gehalten. Ein einzelnes open-Boolean steuert die Sichtbarkeit der Liste, selectedStoreCode und selectedStoreLabel halten die aktuelle Auswahl im Client-State, damit Button-Label und Häkchen in der Liste sofort reagieren, ohne auf den Server-Roundtrip warten zu müssen. Zwei Direktiven sind für ein sauberes Dropdown-Verhalten Pflicht: @click.outside schließt das Dropdown, sobald außerhalb geklickt wird, und @keydown.escape.window schließt es unabhängig davon, welches Element gerade den Fokus hält.

Die Registrierung erfolgt zentral über alpine:init, sodass die Komponente sowohl im Desktop-Header als auch, falls benötigt, in der mobilen Navigation wiederverwendet werden kann. Die Store-View-Daten selbst kommen nicht aus einem Fetch-Request, sondern aus einem data-store-views-Attribut, das das phtml-Template mit json_encode() serverseitig befüllt. Das hält das Dropdown ohne zusätzlichen HTTP-Request beim ersten Rendern funktionsfähig, was insbesondere für Core Web Vitals relevant ist.


// Register the Alpine component once, reused by header.phtml and the mobile menu
document.addEventListener('alpine:init', () => {
  Alpine.data('storeSwitcher', () => ({
    open: false,
    selectedStoreCode: 'default',
    selectedStoreLabel: 'Default Store View',
    storeViews: [],

    init() {
      // storeViews is injected server-side as JSON, see the phtml template
      this.storeViews = JSON.parse(this.$el.dataset.storeViews || '[]');
      const current = this.storeViews.find((view) => view.code === this.selectedStoreCode);
      if (current) {
        this.selectedStoreLabel = current.name;
      }
    },

    selectStore(storeView) {
      this.selectedStoreCode = storeView.code;
      this.selectedStoreLabel = storeView.name;
      this.open = false;
      // Real form submission: the switch controller sets a cookie and redirects
      this.$el.closest('[x-data]').querySelector('#store-switcher-form').submit();
    },
  }));
});

5. Barrierefreiheit: ARIA-Listbox-Pattern und Tastatursteuerung

Ein Dropdown, das nur mit der Maus bedienbar ist, schließt Tastatur- und Screenreader-Nutzer aus. Das ARIA-Combobox/Listbox-Pattern löst das mit klaren Rollen: Der Trigger-Button trägt aria-haspopup="listbox" und ein reaktives :aria-expanded, die Liste selbst bekommt role="listbox", jedes Item role="option" mit aria-selected. Damit kündigt ein Screenreader korrekt an, dass es sich um eine Auswahlliste handelt, welche Option aktuell gewählt ist und ob die Liste gerade geöffnet ist.

Für die Tastatursteuerung reicht @keydown.enter allein nicht aus. Ein vollständiges Listbox-Pattern unterstützt Pfeiltasten zum Navigieren zwischen Optionen sowie Home und End zum Springen an Anfang und Ende der Liste. Roving Tabindex ist dabei die robustere Alternative zu klassischem Tab-Fokus innerhalb der Liste: Nur die aktuell aktive Option ist per Tab erreichbar, die Pfeiltasten verschieben den Fokus programmatisch. Das folgende Muster lässt sich unverändert für beide Header-Dropdowns wiederverwenden:


// Roving-tabindex keyboard navigation for the store switcher listbox
Alpine.data('storeSwitcherKeyboard', () => ({
  activeIndex: 0,

  onArrowDown() {
    this.activeIndex = Math.min(this.activeIndex + 1, this.storeViews.length - 1);
    this.focusActiveOption();
  },

  onArrowUp() {
    this.activeIndex = Math.max(this.activeIndex - 1, 0);
    this.focusActiveOption();
  },

  onHome() {
    this.activeIndex = 0;
    this.focusActiveOption();
  },

  onEnd() {
    this.activeIndex = this.storeViews.length - 1;
    this.focusActiveOption();
  },

  focusActiveOption() {
    const options = this.$refs.listbox.querySelectorAll('[role="option"]');
    const target = options[this.activeIndex];
    if (target) {
      target.focus();
    }
  },
}));

6. Die zweite Komponente: eigenes Formular, eigener Controller

Strukturell ist der Currency-Dropdown dem Store-Dropdown sehr ähnlich, hängt aber an einem anderen Controller: \Magento\Directory\Controller\Currency\SwitchAction. Dieser erwartet den Parameter currency mit dem ISO-Währungscode und schreibt bei Erfolg die gewählte Währung in die Session, verknüpft mit dem aktuellen Store-Scope. Anders als beim Store-Wechsel gibt es hier keinen ___from_store-Parameter, da die Währung innerhalb derselben Store View gewechselt wird und kein Store-Kontextwechsel stattfindet.

Auch dieser Dropdown braucht also ein eigenes verstecktes Formular, das per Alpine programmatisch abgeschickt wird, sobald eine Währung ausgewählt ist. Symbol und Code lassen sich clientseitig aus der bereits serverseitig aufbereiteten Liste anzeigen, ohne dass Alpine selbst Kursdaten oder Formatierungslogik kennen muss, die eigentliche Preisumrechnung bleibt vollständig serverseitig in Magentos Currency-Modell.


<?php
/** @var \Magento\Directory\Block\Currency $block */
?>
<div class="relative" x-data="currencySwitcher()" @click.outside="open = false" @keydown.escape.window="open = false">
  <form id="currency-switcher-form" action="<?= $block->escapeUrl($block->getSwitchCurrencyUrl()) ?>" method="post">
    <input type="hidden" name="currency" x-bind:value="selectedCurrencyCode">
  </form>

  <button
    type="button"
    @click="open = !open"
    :aria-expanded="open.toString()"
    aria-haspopup="listbox"
    aria-controls="currency-switcher-listbox"
    class="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50"
  >
    <span x-text="selectedCurrencyCode"></span>
  </button>

  <ul
    x-show="open"
    x-cloak
    id="currency-switcher-listbox"
    role="listbox"
    aria-label="Currency"
    class="absolute right-0 z-20 mt-2 w-40 rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
  >
    <template x-for="currency in currencies" :key="currency.code">
      <li
        role="option"
        :aria-selected="currency.code === selectedCurrencyCode"
        @click="selectCurrency(currency)"
        tabindex="0"
        class="cursor-pointer px-4 py-2 text-sm hover:bg-orange-50 flex justify-between"
      >
        <span x-text="currency.code"></span>
        <span x-text="currency.symbol" class="text-gray-400"></span>
      </li>
    </template>
  </ul>
</div>

7. Viele Store Views handhaben: Gruppierung und Scroll-Bereich

Shops mit mehreren Websites und Store Groups haben schnell zwanzig oder mehr Store Views, und ein flaches Dropdown wird dann unübersichtlich. Das Store-Dropdown sollte deshalb die Optionen nach Website gruppieren, mit einer eigenen Überschrift pro Gruppe, und die gesamte Liste in einen Bereich mit begrenzter Höhe und overflow-y-auto packen. Alpine übernimmt die Gruppierung clientseitig aus den bereits vorbereiteten Rohdaten, sodass keine zusätzliche Serverlogik für die Anzeige nötig ist.

Die Iteration über verschachtelte Gruppen folgt dem gleichen x-for-Muster wie bei einer flachen Liste, nur mit einer zusätzlichen äußeren Schleife über die Websites. Wichtig ist ein stabiler :key pro Store View, damit Alpine die DOM-Knoten beim Neu-Rendern korrekt zuordnet und nicht unnötig neu erzeugt. Für den Currency-Dropdown ist eine Gruppierung selten nötig, da die Anzahl aktivierter Währungen meist überschaubar bleibt, aber das gleiche Scroll-Pattern schützt auch dort vor überlangen Listen.


// Group store views by website so long lists stay scannable
Alpine.data('groupedStoreSwitcher', () => ({
  open: false,
  websites: [],

  init() {
    const raw = JSON.parse(this.$el.dataset.storeViews || '[]');
    const grouped = {};
    raw.forEach((view) => {
      if (!grouped[view.websiteName]) {
        grouped[view.websiteName] = { name: view.websiteName, storeViews: [] };
      }
      grouped[view.websiteName].storeViews.push(view);
    });
    this.websites = Object.values(grouped);
  },
}));

8. CSP und Performance: kein zusätzliches JS-Bundle

Ein entscheidender Vorteil dieses Ansatzes: Weder das Store-Dropdown noch der Currency-Dropdown benötigen ein zusätzliches JavaScript-Bundle. Alpine.js ist im Hyvä-Theme bereits global geladen, die Komponenten registrieren sich einfach zusätzlich über alpine:init. Das hält die Menge an JavaScript, die pro Seite ausgeliefert wird, konstant, unabhängig davon, wie viele Dropdown-Komponenten im Header zum Einsatz kommen.

Jeder Inline-<script>-Block, der die Alpine-Komponenten registriert, muss anschließend mit $hyvaCsp->registerInlineScript() im Template abgesichert werden, damit die Content Security Policy den generierten Hash kennt und das Skript nicht blockiert. Ohne diese Registrierung würde das Dropdown in Umgebungen mit aktivierter CSP schlicht nicht funktionieren, weil der Browser das Inline-Skript verwirft. Ein kurzer Blick in die Browser-Konsole auf CSP-Violation-Meldungen gehört deshalb zum Standard-Check nach jedem Deploy.

9. Beide Dropdown-Varianten im Vergleich

Der Unterschied zwischen dem klassischen Luma-Dropdown und einem selbst gebauten Alpine-Dropdown für Store-Switcher und Währungsumschalter zeigt sich am deutlichsten bei Bundle-Größe, Barrierefreiheit und Wartbarkeit.

Kriterium Luma UI-Component Dropdown Alpine-Dropdown im Header Auswirkung
Bundle-Größe Eigenes Knockout-Widget + Loader 0 KB zusätzlich, Alpine bereits geladen Kein zusätzlicher HTTP-Request
Barrierefreiheit Kein natives ARIA-Listbox-Pattern role="listbox"/"option", Tastatursteuerung Screenreader-tauglich
Formular-Korrektheit Oft impliziter Reload über Data-Attribute Expliziter Form-Submit an SwitchAction Cookie/Session zuverlässig gesetzt
Wartbarkeit Knockout-Bindings schwer nachvollziehbar x-data direkt im phtml, keine Build-Kette Schnellere Einarbeitung
CSP-Kompatibilität Inline-Templates oft schwer zu hashen registerInlineScript() pro Block Strikte CSP ohne unsafe-inline

In der Praxis heißt das: Der Aufwand, beide Komponenten von Grund auf als Alpine-Dropdown zu bauen, ist überschaubar, weil kein separates JavaScript-Bundle, keine zusätzliche Build-Pipeline und kein Knockout-Kontext gepflegt werden muss. Der einzige Mehraufwand liegt im sorgfältigen ARIA-Markup und im expliziten Formular-Submit, beides Aufgaben, die einmal sauber gelöst und danach für jedes weitere Projekt wiederverwendet werden können.

Mironsoft

Hyvä-Entwicklung, Alpine-Komponenten und barrierefreie Magento-Frontends

Header-Dropdowns, die zuverlässig funktionieren?

Wir bauen Store- und Währungsauswahl als barrierefreies Alpine-Dropdown in euren Hyvä-Header ein, inklusive korrektem Formular-Submit, ARIA-Listbox-Pattern und CSP-konformer Inline-Registrierung.

Header-Audit

Bestehende Header-Dropdowns auf Barrierefreiheit und CSP prüfen

Alpine-Implementierung

Dropdown mit ARIA-Listbox, Tastatursteuerung und echtem Formular-Submit

Multi-Store-Setup

Gruppierung nach Website für Shops mit vielen Store Views

10. Zusammenfassung

Ein sauberes Store-Dropdown und ein sauberer Currency-Dropdown im Hyvä-Header sind keine reinen UI-Fingerübungen, sondern verlangen ein Verständnis des Magento-Datenmodells und der zugehörigen Switch-Controller. Store::getAvailableStoreViews() liefert die Rohdaten für die Store-Auswahl, \Magento\Directory\Model\Currency die Rohdaten für die Währungsauswahl. Beide Wechsel laufen zwingend über einen echten Formular-Submit an SwitchAction, weil nur so Cookie und Session serverseitig aktualisiert werden und der Seiten-Reload den gewählten Zustand behält.

Alpine.js übernimmt ausschließlich die Präsentationsebene: Dropdown öffnen und schließen, ARIA-Attribute synchron halten, Tastatursteuerung mit Pfeiltasten und Escape, Gruppierung nach Website bei vielen Store Views. Da Alpine im Hyvä-Theme bereits global verfügbar ist, kommt für beide Komponenten kein zusätzliches JavaScript-Bundle hinzu, solange jeder Inline-Script-Block korrekt über registerInlineScript() für die Content Security Policy freigegeben wird.

Store- und Währungsauswahl im Hyvä-Header: Das Wichtigste auf einen Blick

Datenmodell

StoreInterface und Currency-Modell liefern die Rohdaten im ViewModel, serialisiert als JSON für Alpine.

Echter Formular-Submit

___store, ___from_store und currency müssen an die jeweilige SwitchAction gesendet werden.

Barrierefreiheit

ARIA-Listbox-Pattern mit role="listbox"/"option", roving tabindex und Escape-Handling.

Performance & CSP

Kein zusätzliches Bundle, Alpine bereits geladen, registerInlineScript() pro Inline-Block.

11. FAQ: Store-Switcher und Währungsumschalter im Hyvä-Header

1Warum reicht ein rein clientseitiges Dropdown nicht?
Weil der Wechsel eine Session-Variable und einen Cookie serverseitig setzt. Ohne Formular-Submit geht die Auswahl beim Reload verloren.
2Welche Parameter erwartet der Store-Switch-Controller?
___store für den Ziel-Store-Code, ___from_store für den Ausgangs-Store, ergänzt um uenc für die Rücksprung-URL.
3Woher kommt die Liste der Store Views?
Store::getAvailableStoreViews() bzw. StoreManagerInterface::getStores(), gefiltert auf isActive() und aktuelle Website.
4Welcher Controller ist für den Wechsel der Anzeigewährung zuständig?
\Magento\Directory\Controller\Currency\SwitchAction, erwartet den Parameter currency mit dem ISO-Code.
5Braucht der Currency-Dropdown auch ___from_store?
Nein, es findet kein Store-Kontextwechsel statt, daher entfällt dieser Parameter.
6Welches ARIA-Pattern passt am besten?
Combobox/Listbox-Pattern mit aria-haspopup, aria-expanded, role=listbox und role=option, plus Pfeiltasten-Navigation.
7Wie schließt man das Dropdown per Klick außerhalb oder Escape?
@click.outside="open = false" und @keydown.escape.window="open = false" auf dem x-data-Container.
8Wie geht man mit vielen Store Views um?
Gruppierung nach Website plus Container mit max-h und overflow-y-auto für scrollbare Listen.
9Braucht es ein zusätzliches JS-Bundle?
Nein, Alpine.js ist im Hyvä-Theme bereits geladen, die Komponente registriert sich zusätzlich über alpine:init.
10Was ist bei CSP zu beachten?
Jeder Inline-Script-Block muss mit $hyvaCsp->registerInlineScript() abgesichert werden, sonst blockiert die CSP das Skript.