Der zweite Teil der Serie dreht sich um Formulare und alles, was auf ihnen aufbaut: Paginierung, Filter und Suche. Navigation, Morph und Zurück beschreibt der erste Teil, modale Formulare und Alpine.js der dritte.
Ganze Seite oder Fragment
Wählen Sie in dieser Reihenfolge:
- Ganze Seite per Boost. Formular und Link werden geboostet, der Server liefert die ganze Seite und Morph führt sie zusammen. Kein zusätzlicher Code auf dem Server. Reicht für die meisten Formulare, Listen, Paginierungen und Filter.
- Fragment mit eigener Route. Eine Adresse, eine Repräsentation. Eignet sich für Lazy Loading.
- Dieselbe Adresse, zwei Repräsentationen. Ein Formular oder eine Liste, die sich innerhalb der Seite ändert und für die die ganze Seite unnötig teuer ist.
Woran der Server erkennt, was kommt
- Ein geboosteter Link oder ein geboostetes Formular sendet
HX-RequestundHX-Boostedund will die ganze Seite. - Ein Element mit eigenem
hx-getoderhx-postsendet nurHX-Requestund will ein Fragment. - Zurück sendet nur
HX-History-Restore-Requestund will die ganze Seite.
Wer nur nach HX-Request fragt, schickt einem geboosteten Link ein Fragment und die Seite verliert ihr Layout. Der Header HX-Request-Type wirkt wie die bessere Unterscheidung, aber die Extension hx-preload sendet ihn nicht, ein per Prefetch geladener Link bekäme also die falsche Antwort. Zuverlässig ist die Kombination:
private function isFragmentRequest(Request $request): bool
{
return $request->headers->has('HX-Request')
&& !$request->headers->has('HX-Boosted');
}
Ein Template für Seite und Fragment
Ein Fragment braucht kein eigenes Template. renderBlock() rendert nur einen Block des Seiten-Templates:
{# contact/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>Kontakt</h1>
{% block form %}
{{ form_start(form, {attr: {id: 'contact-form'}}) }}
{{ form_widget(form) }}
<button>Senden</button>
{{ form_end(form) }}
{% endblock %}
{% endblock %}
{% block sent %}<p id="contact-form">Danke, wir melden uns.</p>{% endblock %}
Für ein Fragment mit eigener Route ist das Attribut #[Template] kürzer:
#[Route('/blog/auswahl', name: 'app_blog_picker')]
#[Template('blog/index.html.twig', block: 'articles')]
public function picker(ArticleRepository $articles): array
{
return ['articles' => $articles->latest()];
}
Wenn Seite und Fragment eine Adresse teilen, setzen Sie Vary, damit der Cache nicht das Fragment statt der Seite liefert:
$response->setVary(['HX-Request', 'HX-Boosted'], false);
Formular
Controller
#[Route('/kontakt', name: 'app_contact', methods: ['GET', 'POST'])]
public function contact(Request $request): Response
{
$form = $this->createForm(ContactType::class);
$form->handleRequest($request);
$isFragment = $this->isFragmentRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// speichern und senden
return $isFragment
? $this->renderBlock('contact/index.html.twig', 'sent')
: $this->redirectToRoute('app_contact');
}
return $isFragment
? $this->renderBlock('contact/index.html.twig', 'form', ['form' => $form])
: $this->render('contact/index.html.twig', ['form' => $form]);
}
render() und renderBlock() setzen den Status 422, wenn sie ein abgeschicktes, ungültiges FormInterface bekommen. Mit $form->createView() bleibt es bei 200 und Sie müssen den Status selbst setzen.
Template
{{ form_start(form, {
action: path('app_contact'),
attr: {
id: 'contact-form',
novalidate: true,
'hx-post': path('app_contact'),
'hx-swap': 'outerHTML',
'hx-sync': 'this:drop',
},
}) }}
actionundmethodbehalten Sie, ohne JavaScript wird das Formular normal abgeschickt.hx-swap="outerHTML", nicht Morph. Morph behält die Werte der Felder, das Formular bliebe nach dem Absenden ausgefüllt.hx-sync="this:drop"verwirft ein zweites Absenden. Für den Zustand während des Absendens dienthx-indicator, eigenen Inhalt fügt die Extensionhx-pendingin das Target ein.novalidate, wenn der Server die Fehler prüft. Firefox für Android zeigt die Meldungen des Browsers nicht an, ein leeres Formular würde nach dem Klick also stillschweigend stehen bleiben.requiredan den Feldern lassen Sie wegen der Screenreader.
Fehlerantworten
422 wird ohne Konfiguration geswappt. Bei einem Fragment wollen Sie aber keine ganze Error Page in das Formular einfügen. hx-status sucht zuerst den exakten Code, erst dann einen Wildcard, 422 muss also separat angegeben werden, sonst verschwinden Validierungsfehler stillschweigend:
<form hx-post="…" hx-target="#order" hx-swap="innerHTML"
hx-status:422="swap:'innerHTML'"
hx-status:4xx="swap:'none'"
hx-status:5xx="swap:'none'">
swap:'none' reicht nicht. hx-head führt den Head der Antwort noch vor der Auswertung von hx-status zusammen, und htmx setzt den Titel, auch wenn nichts geswappt wird. Eine Error Page würde so Titel und Head der aktuellen Seite überschreiben. Head und Titel erlauben Sie deshalb nur beim Swap der ganzen Seite (bei der Variante mit #app vergleichen Sie das Target mit diesem Element):
const isFullPageSwap = (ctx) => ctx.target === document.body
document.addEventListener('htmx:head:before:merge', (event) => {
if (!isFullPageSwap(event.detail.ctx)) event.preventDefault()
})
document.addEventListener('htmx:before:swap', (event) => {
if (!isFullPageSwap(event.detail.ctx)) event.detail.ctx.title = null
})
Ein Serverfehler darf den Text nicht verwerfen. Bei einem Formular, in das der Nutzer lange etwas geschrieben hat, verwerfen Sie eine 5xx-Antwort und fügen unter dem Button Telefon und E-Mail ein. Dasselbe bei einem Netzwerkausfall:
document.addEventListener('htmx:before:response', (event) => {
if (event.detail.ctx.response.status < 500) return
event.preventDefault()
event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})
document.addEventListener('htmx:error', (event) => {
event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})
Redirect nach dem Absenden
- Ein geboostetes Formular kommt mit einem Redirect zurecht und schreibt die endgültige Adresse in die History. Hat das Ziel ein anderes Layout, geben Sie dem Formular
hx-boost="false". Das gilt vor allem für die Anmeldung, bei der der Authenticator nach Erfolg umleitet, und für das Abmelden. hx-postmit einem gewöhnlichen Redirect fügt die ganze Zielseite in das Target ein. Eine ganze Seite in einem kleinen Rahmen ist das typische Symptom.HX-Locationin einer 2xx-Antwort wechselt ohne vollständiges Laden auf eine andere Seite und ändert Adresse und Titel. Eignet sich nach dem erfolgreichen Absenden eines Fragments.HX-Redirecterzwingt ein vollständiges Laden, zum Beispiel zum Payment Gateway oder zu einer Seite mit anderem Layout. Bei einer 3xx-Antwort wertet htmx die Header nicht aus undfetchfolgt dem Redirect von selbst.HX-Refresh: truelädt die aktuelle Seite neu, etwa nach der Anmeldung, wenn sich der ganze Header ändert.
Barrierefreiheit
- Nach dem Swap liegt der Fokus oft auf
<body>. Nach 422 setzen Sie ihn auf das erste fehlerhafte Feld, nach dem Absenden auf die ganze Bestätigung. Beides inhtmx:after:settle. - Während des Absendens verwenden Sie am Button
aria-disabled, nichtdisabled, das den Fokus an den Anfang der Seite werfen würde. - Den Zustand des Absendens melden Sie in einem vorab eingefügten Element mit
role="status".
Paginierung und Filter
Mit der ganzen Seite
Filter als GET-Formular und Paginierung als Links, beides geboostet. Der Server unterscheidet nichts, der State steht in der URL und Zurück funktioniert von selbst. So empfehlen wir es als Standard.
Mit einem Fragment
<a href="{{ path('app_blog', {page: page + 1}) }}"
hx-get="{{ path('app_blog', {page: page + 1}) }}"
hx-target="#articles"
hx-swap="innerHTML"
hx-push-url="true">Weiter</a>
Der Link braucht href und hx-get. Ohne href findet ihn der Crawler nicht und ohne JavaScript funktioniert er nicht. Der Controller liefert den Block per renderBlock() und setzt Vary. Dasselbe Ergebnis erreichen Sie auch ohne Unterscheidung auf dem Server: hx-select="#articles" wählt einen Teil aus der ganzen Seite aus. Der Preis ist eine größere Antwort.
Infinite Scroll
Das letzte Element der Liste lädt die nächste Seite, wenn es in den Viewport kommt:
<li hx-get="{{ path('app_blog', {page: page + 1}) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<a href="{{ path('app_blog', {page: page + 1}) }}">Weitere Artikel</a>
</li>
Den Link darin behalten Sie. Der Crawler scrollt nicht, ohne ihn käme er nicht zu den weiteren Seiten. In einem eigenen scrollbaren Container statt des Fensters verwenden Sie intersect mit root:.
Live-Suche
<input type="search" name="q"
hx-get="{{ path('app_search') }}"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#results"
hx-sync="this:replace">
changed delay: sendet den Request erst nach einer Tipppause und nur, wenn sich der Wert geändert hat, ein schnell getipptes Wort bedeutet also einen Request. hx-sync="this:replace" bricht einen laufenden Request ab, wenn ein neuer kommt. Den abgebrochenen Request meldet htmx in der Konsole als Fehler, der Funktion schadet das nicht. Das Formular um das Feld behalten Sie, ohne JavaScript wird die Suche normal abgeschickt.
SEO von Paginierung und Filtern
- Jede Seite der Liste hat ein eigenes
canonicalmit?page=und einen Titel mit der Nummer. Google rät davon ab, die erste Seite als kanonische zu verwenden. In die Sitemap müssen die weiteren Seiten nicht, Links führen zu ihnen. hreflangauf weiteren Seiten setzen Sie nur, wenn die Sprachversionen dieselben Einträge in derselben Reihenfolge haben. Sonst lassen Sie es weg, ein Link auf eine nicht existierende Seite ist schlechter als keiner.- Gefilterte und sortierte Varianten, die keine Einstiegsseite sein sollen, schließen Sie per
noindexaus.canonicalauf die Liste ohne Parameter ist für die Suchmaschine nur eine Empfehlung. - POST-Pfade von Formularen und Suchergebnisse gehören nicht in die Sitemap.
Updates außerhalb des Targets
Eine Antwort kann mehrere Stellen der Seite ändern, etwa die Meldung zu einem Rabattcode und die Preisübersicht.
hx-swap-oob markiert in der Antwort ein Element, das nach seiner id außerhalb des Haupt-Targets geswappt wird:
{% block discount_feedback %}
{% if error %}<p>{{ error }}</p>{% endif %}
<div id="price-summary" hx-swap-oob="outerHTML">…</div>
{% endblock %}
<hx-partial> ist übersichtlicher, wenn sich mehrere Stellen ändern, jede auf andere Weise. Jedes Partial trägt sein eigenes Target und seinen eigenen Swap:
<hx-partial hx-target="#cart-items" hx-swap="beforeend">…</hx-partial>
<hx-partial hx-target="#cart-count">3</hx-partial>
Worauf Sie achten sollten:
- Leerer Hauptinhalt wird nicht geswappt. Bleibt nach dem Herauslösen der OOB-Elemente in der Antwort nichts übrig, überspringt htmx den Haupt-Swap. Nach einem akzeptierten Rabattcode blieb so auf der Seite der Fehler des zuvor abgelehnten Codes neben dem neuen Preis stehen. Es hilft
hx-swap="innerHTML swapEmpty:true". - Tabellenzeilen und Listeneinträge packen Sie in ein
<template>, sonst verwirft der HTML-Parser sie außerhalb einer Tabelle. - Eine Liste, deren Einträge nach
idhinzukommen und sich ändern, bewältigt die Extensionhx-upsert(hx-swap="upsert key:data-id sort"). Denselben Inhalt an mehrere Stellen schickthx-targetsmit einem Selektor auf einmal.
Checkliste
- Fragment nur bei
HX-Request && !HX-Boosted, nicht nachHX-Request-Type - Fragment per
renderBlock()oder#[Template(block:)], bei gemeinsamer AdresseVary FormInterfaceanrender()undrenderBlock(), nichtcreateView()- Formular mit
action,outerHTML,hx-sync,novalidate hx-status:422neben dem Wildcard4xx, Head und Titel nur beim Swap der ganzen Seite- Der Text des Formulars übersteht 5xx und einen Netzwerkausfall
- Anmelden und Abmelden ohne Boost, nach einem Fragment
HX-LocationoderHX-Redirect, nicht 3xx - Fokus nach 422 und nach dem Absenden,
aria-disabled,role="status" - Paginierung mit
href, Filterzustand in der URL,canonicalauf jeder Seite, Filter mitnoindex - Antwort mit OOB und leerem Hauptinhalt hat
swapEmpty:true