Druhý díl seriálu je o formulářích a o všem, co na nich stojí: stránkování, filtrech a hledání. Navigaci, morph a krok zpět popisuje první díl, modální formuláře a Alpine.js třetí.
Celá stránka, nebo fragment
Vybírejte v tomto pořadí:
- Celá stránka přes boost. Formulář i odkaz se boostují, server vrací celou stránku a morph ji sloučí. Na serveru žádný kód navíc. Stačí pro většinu formulářů, výpisů, stránkování i filtrů.
- Fragment s vlastní routou. Jedna adresa, jedna reprezentace. Hodí se pro lazy loading.
- Tatáž adresa, dvě reprezentace. Formulář nebo výpis, který se mění uvnitř stránky a celá stránka je pro něj zbytečně drahá.
Jak server pozná, co přišlo
- Boostovaný odkaz nebo formulář posílá
HX-RequestiHX-Boosteda chce celou stránku. - Prvek s vlastním
hx-getnebohx-postposílá jenHX-Requesta chce fragment. - Krok zpět posílá jen
HX-History-Restore-Requesta chce celou stránku.
Kdo se ptá jen na HX-Request, pošle boostovanému odkazu fragment a stránka přijde o layout. Hlavička HX-Request-Type vypadá jako lepší rozlišení, ale extension hx-preload ji neposílá, takže prefetchnutý odkaz by dostal špatnou odpověď. Spolehlivá je kombinace:
private function isFragmentRequest(Request $request): bool
{
return $request->headers->has('HX-Request')
&& !$request->headers->has('HX-Boosted');
}
Jedna šablona pro stránku i fragment
Fragment nemusí mít vlastní šablonu. renderBlock() vykreslí jen jeden blok šablony stránky:
{# 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>Odeslat</button>
{{ form_end(form) }}
{% endblock %}
{% endblock %}
{% block sent %}<p id="contact-form">Děkujeme, ozveme se.</p>{% endblock %}
Pro fragment s vlastní routou je kratší atribut #[Template]:
#[Route('/blog/vyber', name: 'app_blog_picker')]
#[Template('blog/index.html.twig', block: 'articles')]
public function picker(ArticleRepository $articles): array
{
return ['articles' => $articles->latest()];
}
Když stránka a fragment sdílejí adresu, nastavte Vary, ať cache nevrátí fragment místo stránky:
$response->setVary(['HX-Request', 'HX-Boosted'], false);
Formulář
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()) {
// uložit a odeslat
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() i renderBlock() nastaví stav 422, když dostanou odeslaný nevalidní FormInterface. S $form->createView() zůstane 200 a stav musíte nastavit sami.
Šablona
{{ 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',
},
}) }}
actionamethodnechte, bez JavaScriptu se formulář odešle běžně.hx-swap="outerHTML", ne morph. Morph zachovává hodnoty polí, formulář by po odeslání zůstal vyplněný.hx-sync="this:drop"zahodí druhé odeslání. Pro stav během odeslání sloužíhx-indicator, vlastní obsah do targetu vloží extensionhx-pending.novalidate, když chyby hlídá server. Firefox pro Android hlášky prohlížeče nezobrazuje, takže by prázdný formulář po kliknutí tiše stál.requiredu polí nechte kvůli čtečkám.
Chybové odpovědi
422 se swapne bez konfigurace. U fragmentu ale nechcete do formuláře vložit celou error page. hx-status hledá nejdřív přesný kód, až potom wildcard, takže 422 musí být uvedená zvlášť, jinak validační chyby tiše zmizí:
<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' nestačí. hx-head sloučí hlavičku odpovědi ještě před vyhodnocením hx-status a htmx nastaví titulek, i když nic neswapne. Error page by tak přepsala titulek a hlavičku aktuální stránky. Head a titulek proto povolte jen při swapu celé stránky (u varianty s #app porovnejte target s tímto prvkem):
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
})
Chyba serveru nesmí zahodit text. U formuláře, kde uživatel něco dlouho psal, odpověď 5xx zahoďte a pod tlačítko vložte telefon a e-mail. Totéž při výpadku sítě:
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 po odeslání
- Boostovaný formulář redirect zvládne a do historie zapíše konečnou adresu. Když má cíl jiný layout, dejte formuláři
hx-boost="false". To platí hlavně pro přihlášení, kde authenticator po úspěchu přesměrovává, a pro odhlášení. hx-posts obyčejným redirectem vloží celou cílovou stránku do targetu. Celá stránka v malém rámečku je typický příznak.HX-Locationv odpovědi 2xx přejde na jinou stránku bez plného načtení, změní adresu i titulek. Hodí se po úspěšném odeslání fragmentu.HX-Redirectvynutí plné načtení, například na platební bránu nebo stránku s jiným layoutem. Na odpovědi 3xx htmx hlavičky nezpracovává afetchredirect sám následuje.HX-Refresh: truenačte aktuální stránku znovu, třeba po přihlášení, kdy se mění celá hlavička.
Přístupnost
- Po swapu bývá fokus na
<body>. Po 422 ho dejte na první chybné pole, po odeslání na celé potvrzení. Obojí vhtmx:after:settle. - Během odesílání použijte na tlačítku
aria-disabled, nedisabled, který by fokus shodil na začátek stránky. - Stav odesílání ohlaste v předem vloženém prvku s
role="status".
Stránkování a filtry
Celou stránkou
Filtr jako GET formulář a stránkování jako odkazy, obojí boostované. Server nic nerozlišuje, stav je v URL a krok zpět funguje sám. Tak to doporučujeme jako výchozí.
Fragmentem
<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">Další</a>
Odkaz potřebuje href i hx-get. Bez href ho robot nenajde a bez JavaScriptu nefunguje. Controller vrátí blok přes renderBlock() a nastaví Vary. Stejného výsledku dosáhnete i bez rozlišování na serveru: hx-select="#articles" vybere část z celé stránky. Platí se za to větší odpovědí.
Infinite scroll
Poslední prvek seznamu načte další stranu, když se dostane do viewportu:
<li hx-get="{{ path('app_blog', {page: page + 1}) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
<a href="{{ path('app_blog', {page: page + 1}) }}">Další články</a>
</li>
Odkaz uvnitř nechte. Robot neposouvá, takže bez něj by se k dalším stranám nedostal. Ve vlastním posuvném kontejneru místo okna použijte intersect s root:.
Živé hledání
<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: pošle request až po pauze v psaní a jen při změně hodnoty, takže rychle napsané slovo znamená jeden request. hx-sync="this:replace" zruší rozpracovaný request, když přijde nový. Zrušený request htmx ohlásí v konzoli jako chybu, funkci to nevadí. Formulář kolem pole nechte, bez JavaScriptu se hledání odešle běžně.
SEO stránkování a filtrů
- Každá strana výpisu má vlastní
canonicals?page=a titulek s číslem. Google nedoporučuje používat jako kanonickou první stranu. Do sitemapy další strany dávat nemusíte, vedou k nim odkazy. hreflangna dalších stranách dejte, jen když mají jazykové verze stejné položky ve stejném pořadí. Jinak ho vynechte, odkaz na neexistující stranu je horší než žádný.- Filtrované a řazené varianty, které nemají být vstupní stránkou, vyřaďte přes
noindex.canonicalna výpis bez parametru je pro vyhledávač jen doporučení. - Formulářové POST cesty a výsledky hledání do sitemapy nepatří.
Aktualizace mimo target
Jedna odpověď může změnit víc míst na stránce, třeba hlášku ke slevovému kódu a souhrn ceny.
hx-swap-oob v odpovědi označí prvek, který se swapne podle svého id mimo hlavní target:
{% block discount_feedback %}
{% if error %}<p>{{ error }}</p>{% endif %}
<div id="price-summary" hx-swap-oob="outerHTML">…</div>
{% endblock %}
<hx-partial> je čitelnější, když se mění víc míst a každé jinak. Každý partial nese vlastní target a swap:
<hx-partial hx-target="#cart-items" hx-swap="beforeend">…</hx-partial>
<hx-partial hx-target="#cart-count">3</hx-partial>
Na co si dát pozor:
- Prázdný hlavní obsah se neswapne. Když po vyjmutí OOB prvků v odpovědi nic nezbude, htmx hlavní swap přeskočí. Po přijetí slevového kódu tak na stránce zůstala chyba z předchozího odmítnutého kódu vedle nové ceny. Pomůže
hx-swap="innerHTML swapEmpty:true". - Řádky tabulky a položky seznamu zabalte do
<template>, jinak je parser HTML mimo tabulku zahodí. - Seznam, kterému přibývají a mění se položky podle
id, zvládne extensionhx-upsert(hx-swap="upsert key:data-id sort"). Stejný obsah do víc míst naráz pošlehx-targetsse selektorem.
Kontrolní seznam
- Fragment jen při
HX-Request && !HX-Boosted, ne podleHX-Request-Type - Fragment přes
renderBlock()nebo#[Template(block:)], u společné adresyVary FormInterfacedorender()irenderBlock(), necreateView()- Formulář s
action,outerHTML,hx-sync,novalidate hx-status:422vedle wildcardu4xx, head a titulek jen při swapu celé stránky- Text formuláře přežije 5xx i výpadek sítě
- Přihlášení a odhlášení bez boostu, po fragmentu
HX-LocationneboHX-Redirect, ne 3xx - Fokus po 422 i po odeslání,
aria-disabled,role="status" - Stránkování s
href, stav filtrů v URL,canonicalna každé straně, filtry snoindex - Odpověď s OOB a prázdným hlavním obsahem má
swapEmpty:true