Eine Komponente für Login-Popup und Größentabelle
Wer für jeden Modal-Dialog im Hyvä-Theme eine eigene Alpine-Komponente baut, vervielfacht Wartungsaufwand und Bugs bei Fokus-Handling, Escape-Taste und z-index. Dieser Artikel zeigt, wie eine einzige generische Alpine-Modal-Komponente per Alpine.store gleichzeitig ein Login-Popup und eine Produkt-Größentabelle auf der PDP steuert, inklusive x-teleport gegen Overflow-Clipping, Focus-Trap, Escape- und Klick-außerhalb-Handling sowie CSP-sicherer Umsetzung mit registerInlineScript.
Inhaltsverzeichnis
- 1. Warum Modal-Dialoge in Hyvä ein eigenes Pattern brauchen
- 2. Eine generische Alpine-Modal-Komponente entwerfen
- 3. Alpine.store als zentrale Modal-Verwaltung
- 4. x-teleport: Modal-Markup ans Ende von body verschieben
- 5. Klick außerhalb und Escape zum Schließen
- 6. Fokus-Management und Focus-Trap
- 7. Übergänge mit x-transition für Backdrop und Panel
- 8. Login-Modal und Größentabelle: zwei Anwendungsfälle, eine Komponente
- 9. Modal-Dialoge im Vergleich: Luma/Bootstrap vs. Alpine
- 10. Zusammenfassung
- 11. FAQ
1. Warum Modal-Dialoge in Hyvä ein eigenes Pattern brauchen
Hyvä verzichtet bewusst auf Bootstrap, jQuery und Magentos UI-Components: damit fehlt auch das aus Luma bekannte Bootstrap-Modal, das Login-Popup, Größentabelle und Cookie-Hinweis mit einer einzigen JS-Bibliothek erledigt hat. In der Praxis entstehen dadurch schnell mehrere Ad-hoc-Lösungen: ein x-show für das Login-Popup hier, ein separates x-data-Konstrukt für die Größentabelle dort, jeweils mit eigener Logik für Öffnen, Schließen und Fokus. Ein sauberer Modal-Dialog braucht aber immer dieselben Bausteine, unabhängig davon, ob er einen Login-Formular oder eine CMS-Größentabelle zeigt.
Genau hier setzt eine generische Alpine-Modal-Komponente an: ein einziges, wiederverwendbares Muster aus x-data, Alpine.store und x-teleport, das an beliebiger Stelle im Theme per ID referenziert wird. Statt für jeden neuen Modal-Dialog erneut Fokus-Handling, Escape-Taste und Backdrop-Klick zu implementieren, registriert man die Komponente einmal zentral und bindet sie über eine ID wie login oder size-chart ein. Das reduziert Code-Duplikate, vereinheitlicht die Barrierefreiheit und macht neue Anwendungsfälle zu einer Frage von Markup, nicht von neuer JavaScript-Logik.
2. Eine generische Alpine-Modal-Komponente entwerfen
Der Kern jedes Alpine-Modal-Patterns ist eine Alpine.data-Factory, die mit einer Modal-ID parametrisiert wird. Die Komponente selbst hält keinen eigenen offen/geschlossen-Zustand: dieser liegt zentral im Store (dazu mehr im nächsten Abschnitt). Stattdessen kapselt modalDialog(id) die Verhaltenslogik: eine berechnete Eigenschaft isOpen, die den Store abfragt, eine open()-Methode, die sich das zuletzt fokussierte Element merkt, und eine close()-Methode, die den Fokus beim Schließen zurückgibt.
Diese Trennung (Zustand im Store, Verhalten in der Komponente) ist der entscheidende Unterschied zu einem naiven x-data="{ open: false }" pro Modal. Sie erlaubt, dass ein Trigger-Button irgendwo im Header und das eigentliche Panel am Ende von <body> stehen, ohne dass beide denselben x-data-Scope teilen müssen. Der init()-Hook der Komponente registriert außerdem einen Watcher auf isOpen, der beim Öffnen automatisch das erste fokussierbare Element im Panel fokussiert: ein Detail, das in den meisten Ad-hoc-Modals schlicht fehlt.
document.addEventListener('alpine:init', () => {
// Generic modal component, parameterized by a modal id.
// State (which modal is active) lives in the store, not here.
Alpine.data('modalDialog', (modalId) => ({
modalId: modalId,
triggerEl: null,
get isOpen() {
return this.$store.modal.active === this.modalId;
},
open(triggerEl = null) {
// Remember the element that triggered the modal to restore focus later
this.triggerEl = triggerEl || document.activeElement;
this.$store.modal.open(this.modalId);
},
close() {
this.$store.modal.close();
this.$nextTick(() => {
if (this.triggerEl) {
this.triggerEl.focus();
}
});
},
init() {
this.$watch('isOpen', (value) => {
if (value) {
this.$nextTick(() => this.focusFirstElement());
}
});
},
focusFirstElement() {
const panel = this.$refs.panel;
if (!panel) return;
const selector = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusable = panel.querySelector(selector);
if (focusable) focusable.focus();
}
}));
});
3. Alpine.store als zentrale Modal-Verwaltung
Ein Shop hat meist mehrere Stellen, die denselben Modal-Dialog öffnen können: der Login-Link im Header, der Account-Icon in der Mobile-Navigation und eventuell ein Hinweis im Checkout, der zur Anmeldung auffordert. Alle drei sollen dasselbe Login-Popup öffnen, nicht drei unabhängige Kopien davon. Die Lösung ist ein globaler Alpine.store('modal', ...), der genau eine Eigenschaft hält: die ID des aktuell aktiven Modals. Jeder Trigger-Button ruft einfach $store.modal.open('login') auf, unabhängig davon, wo im DOM er steht.
Diese zentrale Modal-Verwaltung löst gleich zwei Probleme: Erstens kann zu jedem Zeitpunkt immer nur ein Modal-Dialog aktiv sein: ein zweiter open()-Aufruf überschreibt einfach die aktive ID, es entstehen keine übereinanderliegenden Overlays. Zweitens lässt sich an zentraler Stelle body-Scrolling sperren, sobald irgendein Modal offen ist, ohne dass jede einzelne Komponente das selbst verwalten muss. Die Methode isOpen(id) macht den Store außerdem für x-show-Bindings in beliebigen Templates abfragbar, ohne dass diese Templates den internen Zustand einer bestimmten Komponente kennen müssen.
document.addEventListener('alpine:init', () => {
// Central registry: exactly one modal id can be active at a time
Alpine.store('modal', {
active: null,
open(id) {
this.active = id;
document.documentElement.classList.add('overflow-hidden');
},
close() {
this.active = null;
document.documentElement.classList.remove('overflow-hidden');
},
isOpen(id) {
return this.active === id;
}
});
});
4. x-teleport: Modal-Markup ans Ende von body verschieben
Ein Modal-Dialog, der irgendwo tief verschachtelt im Markup liegt (etwa innerhalb eines Produkt-Sliders oder eines Headers mit overflow-hidden), wird von seinem Elternelement abgeschnitten oder von einem anderen Stacking-Context überdeckt. Genau dieses Problem löst x-teleport: Die Direktive verschiebt den DOM-Knoten zur Laufzeit an eine andere Stelle im Dokument, während der reaktive Alpine-Scope erhalten bleibt. Für Alpine-Modal-Komponenten ist x-teleport="body" der Standardfall: das Panel landet damit garantiert außerhalb jeder Eltern-Stacking-Context und jedes overflow: hidden.
Wichtig ist, dass x-teleport nur auf einem <template>-Element funktioniert, nicht direkt auf einem <div>. Das Template selbst bleibt an seiner ursprünglichen Stelle im Quellcode stehen, nur der gerenderte Inhalt wird ans Ende von <body> verschoben. Das erlaubt, den Trigger-Button und den Dialog im phtml-Template logisch zusammenzuhalten, etwa im selben Block, während die tatsächliche DOM-Position davon komplett entkoppelt ist. Der z-index von z-50 auf dem Wurzelelement reicht danach aus, da keine tiefer verschachtelten Elternelemente mehr im Weg stehen.
<!-- Trigger button anywhere in the header markup -->
<button
type="button"
class="text-sm font-semibold text-gray-700 hover:text-orange-600"
x-data
@click="$store.modal.open('login')"
>
Login
</button>
<!-- Modal markup: logically kept next to the trigger, physically teleported to body -->
<template x-teleport="body">
<div
x-data="modalDialog('login')"
x-show="isOpen"
x-cloak
class="fixed inset-0 z-50"
role="dialog"
aria-modal="true"
aria-labelledby="login-modal-title"
@keydown.escape.window="isOpen && close()"
>
<div
class="fixed inset-0 bg-gray-900/60"
x-show="isOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="close()"
></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div
x-ref="panel"
x-show="isOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="bg-white rounded-2xl shadow-xl max-w-md w-full p-6"
>
<h3 id="login-modal-title" class="text-lg font-bold mb-4">Anmelden</h3>
<!-- Login form partial included here -->
</div>
</div>
</div>
</template>
5. Klick außerhalb und Escape zum Schließen
Ein Modal-Dialog muss auf zwei Arten schließbar sein, ohne dass der Nutzer den Schließen-Button treffen muss: per Klick außerhalb des Panels und per Escape-Taste. Für den Klick außerhalb hat sich in der Praxis ein dedizierter Backdrop-Layer bewährt: ein separates div, das den gesamten Viewport abdeckt und ein simples @click="close()" trägt, statt @click.outside direkt auf dem Panel. Der Grund: @click.outside reagiert auf jeden Klick außerhalb des Elements, auch auf Klicks in andere teleportierte Overlays wie ein Datepicker oder ein natives Select-Dropdown, die visuell zwar innerhalb des Panels erscheinen, DOM-technisch aber außerhalb liegen.
Für die Escape-Taste bindet man @keydown.escape.window auf das Wurzelelement des Modal-Dialogs. Der .window-Modifier sorgt dafür, dass das Event unabhängig vom aktuellen Fokus im Dokument abgefangen wird, auch wenn der Fokus gerade in einem Formularfeld innerhalb des Panels liegt. Die Bedingung isOpen && close() stellt sicher, dass ein Escape-Tastendruck nur das jeweils aktive Modal schließt, nicht versehentlich ein anderes, unsichtbares Modal-Element, das ebenfalls auf dasselbe Event lauscht. Diese beiden Muster zusammen decken die von Nutzern erwarteten Standard-Interaktionen für Overlays dieser Art vollständig ab.
6. Fokus-Management und Focus-Trap
Barrierefreiheit ist bei einem Modal-Dialog keine Kür, sondern Pflicht: Sobald das Modal öffnet, muss der Tastaturfokus ins Panel wandern, und beim Schließen muss er zum ursprünglichen Trigger-Element zurückkehren. Genau das übernimmt die open()-Methode aus Abschnitt 2, die document.activeElement vor dem Öffnen speichert, sowie der Watcher, der nach dem Öffnen automatisch das erste fokussierbare Element im Panel fokussiert. Ohne dieses Verhalten bleibt der Fokus optisch im Hintergrund hängen, während der Inhalt visuell im Vordergrund liegt: ein klassischer Screenreader-Bug.
Der zweite Baustein ist der Focus-Trap: Solange das Modal geöffnet ist, darf die Tab-Taste den Fokus nicht aus dem Panel heraus auf Elemente im Hintergrund springen lassen. Dazu wird bei jedem Tab-Tastendruck geprüft, ob der Fokus gerade auf dem letzten fokussierbaren Element im Panel liegt, falls ja springt der Fokus zurück auf das erste Element, und umgekehrt bei Shift+Tab. Diese Logik lässt sich als einfache Funktion implementieren und über @keydown.tab auf dem Panel binden, ganz ohne externe Focus-Trap-Bibliothek.
// Cycles Tab focus within the modal panel, called via @keydown.tab on x-ref="panel"
function trapFocus(event, panelEl) {
const selector = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = panelEl.querySelectorAll(selector);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
// Bound in markup as: @keydown.tab="trapFocus($event, $refs.panel)"
7. Übergänge mit x-transition für Backdrop und Panel
Ein Modal-Dialog ohne Übergangsanimation wirkt abrupt und erschwert die Wahrnehmung, dass gerade ein neuer Kontext geöffnet wurde. Alpine liefert dafür x-transition direkt mit, ohne zusätzliche Library. Backdrop und Panel bekommen dabei bewusst unterschiedliche Timings: Der Backdrop blendet über opacity-0 zu opacity-100 in 200ms ein, das Panel kombiniert zusätzlich scale-95 zu scale-100, ebenfalls über x-transition:enter und x-transition:leave. Der leichte Scale-Effekt auf dem Panel macht den Öffnen-Vorgang fühlbar, ohne dass die Animation träge wirkt.
Beim Zusammenspiel mit x-teleport ist wichtig, dass x-show weiterhin auf dem teleportierten Inhalt selbst sitzt, nicht auf dem äußeren <template>. Alpine wendet die Transition auf das tatsächlich gerenderte Element an, unabhängig davon, wo es im DOM-Baum steht. Das Teleportieren selbst beeinflusst die Transition-Mechanik nicht. Wer stattdessen mit x-if arbeitet, um das Modal-Markup komplett aus dem DOM zu entfernen statt es nur zu verstecken, sollte das nur außerhalb der Transition-Phase tun, da x-if das Element sofort entfernt und keine Leave-Transition mehr abspielt.
8. Login-Modal und Größentabelle: zwei Anwendungsfälle, eine Komponente
Das Login-Popup ersetzt in Hyvä den klassischen Redirect auf customer/account/login: Statt eine neue Seite zu laden, öffnet ein Klick auf den Header-Login-Link den Modal-Dialog mit ID login, der ein normalerweise verstecktes phtml-Template mit dem Kunden-Login-Formular rendert. Das Formular selbst bleibt unverändert: Magentos Standard-Formularvalidierung und der reguläre POST-Endpunkt funktionieren identisch, nur die Präsentation ändert sich von einer eigenen Seite zu einem Overlay. Bei einem Login-Fehler bleibt das Modal einfach geöffnet und zeigt die Standard-Fehlermeldung im Panel an.
Die Größentabelle auf der PDP nutzt exakt dieselbe modalDialog-Komponente, nur mit der ID size-chart und anderem Inhalt: statt eines Login-Formulars wird ein CMS-Block per getBlockHtml in das Panel gerendert. Der entscheidende Vorteil der generischen Komponente zeigt sich hier: Trigger-Button, Teleport, Focus-Trap, Escape-Handling und Transitions müssen für die Größentabelle nicht neu geschrieben werden, nur das Markup im Panel unterscheidet sich. Der CMS-Block wird dabei serverseitig ins Panel gerendert, sodass kein zusätzlicher AJAX-Request beim Öffnen nötig ist.
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Hyva\Theme\Model\ViewModelRegistry $viewModels */
/** @var \Hyva\Theme\ViewModel\HyvaCsp $hyvaCsp */
$hyvaCsp = $viewModels->require(\Hyva\Theme\ViewModel\HyvaCsp::class);
?>
<!-- Trigger button next to the size swatches on the PDP -->
<button
type="button"
class="text-xs font-semibold text-orange-700 underline"
x-data
@click="$store.modal.open('size-chart')"
>
<?= $block->escapeHtml(__('Size Chart')) ?>
</button>
<template x-teleport="body">
<div
x-data="modalDialog('size-chart')"
x-show="isOpen"
x-cloak
class="fixed inset-0 z-50"
role="dialog"
aria-modal="true"
@keydown.escape.window="isOpen && close()"
>
<div class="fixed inset-0 bg-gray-900/60" x-show="isOpen" @click="close()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div x-ref="panel" x-show="isOpen" class="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-6 overflow-y-auto max-h-[80vh]">
<h3 class="text-lg font-bold mb-4"><?= $block->escapeHtml(__('Size Chart')) ?></h3>
<?= $block->getLayout()->createBlock(\Magento\Cms\Block\Block::class)
->setBlockId('product-size-chart')
->toHtml() ?>
</div>
</div>
</div>
</template>
<script>
// No inline onclick attributes anywhere, only declarative x-on bindings
document.addEventListener('alpine:init', () => {
Alpine.store('modal', { active: null, open(id) { this.active = id; }, close() { this.active = null; }, isOpen(id) { return this.active === id; } });
});
</script>
<?php $hyvaCsp->registerInlineScript(); ?>
9. Modal-Dialoge im Vergleich: Luma/Bootstrap vs. Alpine
Der Umstieg von Lumas Bootstrap-Modal auf eine schlanke Alpine-Modal-Komponente betrifft nicht nur Bundle-Größe und Ladezeit, sondern vor allem Barrierefreiheit und Wartbarkeit. Die folgende Tabelle stellt die wichtigsten Unterschiede gegenüber, wie sie in Migrationsprojekten von Luma nach Hyvä regelmäßig auftauchen.
| Kriterium | Bootstrap/jQuery-Modal (Luma) | Alpine-Modal-Komponente | Vorteil |
|---|---|---|---|
| Bundle-Größe | jQuery + Bootstrap JS, ~90 KB zusätzlich | Alpine.js, bereits in Hyvä geladen | Kein zusätzliches JS-Bundle nötig |
| Fokus & Barrierefreiheit | Kein Focus-Trap, Tab verlässt das Modal | Focus-Trap + Fokus-Rückgabe an Trigger | WCAG-konformes Tastatur-Verhalten |
| z-index / Overflow | Clipping durch overflow-hidden-Eltern |
x-teleport ans Ende von body | Kein Clipping, immer oberste Ebene |
| Wiederverwendbarkeit | Eigenes Markup + JS pro Anwendungsfall | Eine Komponente für Login, Größentabelle, mehr | Weniger Code, ein Wartungsort |
| CSP-Kompatibilität | Inline onclick, oft CSP-Verstöße |
Deklarative x-Direktiven, registerInlineScript | CSP-sicher ohne unsafe-inline |
In der Summe zeigt sich: Eine generische Alpine-Modal-Komponente ist nicht nur schlanker, sondern auch konsequenter in Sachen Barrierefreiheit, weil Fokus-Handling und Escape-Verhalten einmalig korrekt implementiert und danach für jeden weiteren Anwendungsfall wiederverwendet werden, statt bei jeder neuen Anforderung erneut aus Bootstrap-Beispielen zusammenkopiert zu werden.
10. Zusammenfassung
Eine generische Alpine-Modal-Komponente löst das immer gleiche Grundproblem hinter Login-Popup, Größentabelle und jedem weiteren Modal-Dialog im Hyvä-Theme: Zustand gehört zentral in einen Alpine.store, damit beliebige Trigger-Buttons dasselbe Modal öffnen können. x-teleport verschiebt das Panel-Markup ans Ende von <body> und beseitigt damit Clipping- und z-index-Probleme, die bei tief verschachteltem Markup unvermeidlich auftreten. Focus-Trap, Escape-Handling und Fokus-Rückgabe an den Trigger sind keine optionalen Extras, sondern Grundvoraussetzung für ein barrierefreies Ergebnis.
Der praktische Nutzen zeigt sich am deutlichsten dort, wo dieselbe Komponente mehrere Anwendungsfälle bedient: Das Login-Modal ersetzt den Redirect auf die Login-Seite, die Größentabelle bindet einen CMS-Block ein: beide nutzen exakt dieselbe modalDialog-Factory, denselben Store und dieselbe Teleport-Logik. Neue Modal-Anwendungsfälle werden dadurch zu einer Frage von Markup und CMS-Inhalt, nicht von neuem JavaScript. In Kombination mit registerInlineScript bleibt die Umsetzung außerdem vollständig CSP-konform, ohne auf unsafe-inline zurückzugreifen.
Modal-Dialoge mit Alpine.js in Hyvä: Das Wichtigste auf einen Blick
Alpine.store als Modal-Registry
Eine aktive Modal-ID im Store steuert beliebig viele Trigger-Buttons, nur ein Modal ist je zur Zeit geöffnet.
x-teleport gegen Clipping
Modal-Markup landet zur Laufzeit am Ende von body, kein overflow-hidden oder z-index-Konflikt mehr.
Focus-Trap & Escape
Tab bleibt im Panel gefangen, Escape schließt zuverlässig, Fokus kehrt zum Trigger-Element zurück.
CSP-sichere Umsetzung
Keine inline onclick-Attribute, nur deklarative x-Direktiven plus registerInlineScript für Store-Definitionen.
11. FAQ: Modal-Dialoge mit Alpine.js in Hyvä
1Was ist eine Alpine-Modal-Komponente?
2Wie funktioniert Alpine.store dabei?
3Warum x-teleport verwenden?
4Wie schließe ich per Escape?
5Was ist ein Focus-Trap?
6Wie baue ich ein Login-Modal?
7Wie kommt die Größentabelle ins Modal?
8Ist das CSP-kompatibel?
9Wie unterscheiden sich mehrere Modals?
10Welche Performance-Vorteile gibt es?
Mironsoft
Hyvä-Frontend, Alpine-Komponenten und barrierefreie UI-Patterns
Modal-Dialoge, die überall funktionieren - Login bis Größentabelle?
Wir bauen wiederverwendbare Alpine-Modal-Komponenten für euer Hyvä-Theme - mit Alpine.store, x-teleport, Focus-Trap und CSP-sicherer Umsetzung, statt Ad-hoc-Overlays für jeden neuen Anwendungsfall.
Komponenten-Audit
Bestehende Modal-Implementierungen auf Fokus, Escape und CSP prüfen
Login-Modal
Mini-Login-Popup statt vollem Seiten-Reload implementieren
PDP-Größentabelle
CMS-gestützte Größentabelle als wiederverwendbares Modal einbinden