Aller au contenu

htmx

htmx permet d’ajouter des comportements AJAX directement dans le HTML :

<button
hx-post="/cart/add/42"
hx-target="#cart-count"
hx-swap="innerHTML"
>
Ajouter au panier
</button>
# tailstore_cart.libraries.yml
htmx:
version: 2.0
js:
https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js:
type: external
minified: true
attributes:
defer: true
# Ou en local
htmx_local:
version: 2.0
js:
js/vendor/htmx.min.js: { minified: true }
// tailstore_cart.module
/**
* Implements hook_page_attachments().
*/
function tailstore_cart_page_attachments(array &$attachments): void {
$attachments['#attached']['library'][] = 'tailstore_cart/htmx';
}
{# templates/add-to-cart.html.twig #}
<button
class="btn btn-primary"
hx-post="/cart/add/{{ product_id }}"
hx-target="#mini-cart"
hx-swap="innerHTML"
hx-indicator="#cart-loading"
hx-vals='{"quantity": 1}'
>
<span class="htmx-indicator" id="cart-loading">
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">...</svg>
</span>
<span>Ajouter au panier</span>
</button>
<?php
namespace Drupal\tailstore_cart\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class CartController extends ControllerBase {
/**
* Add product to cart (htmx endpoint).
*/
public function add(int $product_id, Request $request): Response {
$quantity = (int) $request->request->get('quantity', 1);
$this->cartService->add($product_id, $quantity);
// Détecter requête htmx
if ($request->headers->has('HX-Request')) {
return $this->renderMiniCart();
}
// Fallback JSON pour JS classique
return new JsonResponse([
'success' => TRUE,
'count' => $this->cartService->getCount(),
]);
}
/**
* Render mini-cart HTML fragment.
*/
private function renderMiniCart(): Response {
$build = [
'#theme' => 'mini_cart',
'#count' => $this->cartService->getCount(),
'#total' => $this->cartService->getTotal(),
'#items' => $this->cartService->getItems(),
];
$html = \Drupal::service('renderer')->renderRoot($build);
$response = new Response($html);
// Headers htmx optionnels
$response->headers->set('HX-Trigger', 'cartUpdated');
return $response;
}
}
AttributDescription
hx-getRequête GET
hx-postRequête POST
hx-putRequête PUT
hx-patchRequête PATCH
hx-deleteRequête DELETE
AttributDescription
hx-targetÉlément à mettre à jour
hx-swapComment remplacer le contenu
hx-selectSélectionner une partie de la réponse
ValeurDescription
innerHTMLRemplace le contenu (défaut)
outerHTMLRemplace tout l’élément
beforebeginAvant l’élément
afterbeginAu début du contenu
beforeendÀ la fin du contenu
afterendAprès l’élément
deleteSupprime l’élément
nonePas de swap
AttributDescription
hx-triggerÉvénement déclencheur
hx-indicatorÉlément loading
hx-valsValeurs à envoyer (JSON)
hx-confirmDemander confirmation
hx-push-urlMettre à jour l’URL
{# templates/mini-cart.html.twig #}
<div id="mini-cart" class="relative" hx-get="/cart/mini" hx-trigger="cartUpdated from:body">
{# Icône panier #}
<button
class="p-2 relative"
hx-get="/cart/dropdown"
hx-target="#cart-dropdown"
hx-swap="innerHTML"
hx-trigger="click"
>
<svg class="w-6 h-6">...</svg>
{% if count > 0 %}
<span class="absolute -top-1 -right-1 w-5 h-5 bg-primary text-white text-xs rounded-full flex items-center justify-center">
{{ count }}
</span>
{% endif %}
</button>
{# Dropdown #}
<div id="cart-dropdown" class="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-xl hidden">
{# Chargé dynamiquement #}
</div>
</div>
{# templates/cart-dropdown.html.twig #}
<div class="p-4">
{% if items is empty %}
<p class="text-center text-gray-500 py-4">Votre panier est vide</p>
{% else %}
<ul class="divide-y max-h-64 overflow-y-auto">
{% for item in items %}
<li class="py-3 flex gap-3" id="cart-item-{{ item.product.id }}">
<img src="{{ item.product.field_images.0.entity.uri.value|image_style('thumbnail') }}"
class="w-16 h-16 object-cover rounded">
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ item.product.label }}</p>
<p class="text-sm text-gray-500">
{{ item.quantity }} × {{ item.price|number_format(2, ',', ' ') }} €
</p>
</div>
<button
class="text-gray-400 hover:text-red-500"
hx-delete="/cart/remove/{{ item.product.id }}"
hx-target="#cart-item-{{ item.product.id }}"
hx-swap="outerHTML"
hx-confirm="Supprimer cet article ?"
>
</button>
</li>
{% endfor %}
</ul>
<div class="border-t pt-4 mt-4">
<div class="flex justify-between font-bold mb-4">
<span>Total</span>
<span>{{ total|number_format(2, ',', ' ') }} €</span>
</div>
<a href="/checkout" class="btn btn-primary w-full">
Commander
</a>
</div>
{% endif %}
</div>
{# templates/cart-item.html.twig #}
<tr id="cart-item-{{ product.id }}" class="border-b">
<td class="py-4">
<div class="flex items-center gap-4">
<img src="{{ product.field_images.0.entity.uri.value|image_style('thumbnail') }}"
class="w-20 h-20 object-cover rounded">
<div>
<h3 class="font-medium">{{ product.label }}</h3>
<p class="text-sm text-gray-500">{{ product.field_sku.value }}</p>
</div>
</div>
</td>
<td class="py-4">
{{ price|number_format(2, ',', ' ') }} €
</td>
<td class="py-4">
<div class="flex items-center border rounded-lg overflow-hidden w-fit">
<button
class="px-3 py-2 bg-gray-100 hover:bg-gray-200"
hx-patch="/cart/update/{{ product.id }}"
hx-target="#cart-item-{{ product.id }}"
hx-swap="outerHTML"
hx-vals='{"quantity": {{ quantity - 1 }}}'
{% if quantity <= 1 %}disabled{% endif %}
></button>
<input
type="number"
value="{{ quantity }}"
min="1"
class="w-16 text-center border-0"
hx-patch="/cart/update/{{ product.id }}"
hx-target="#cart-item-{{ product.id }}"
hx-swap="outerHTML"
hx-trigger="change"
hx-include="this"
name="quantity"
>
<button
class="px-3 py-2 bg-gray-100 hover:bg-gray-200"
hx-patch="/cart/update/{{ product.id }}"
hx-target="#cart-item-{{ product.id }}"
hx-swap="outerHTML"
hx-vals='{"quantity": {{ quantity + 1 }}}'
>+</button>
</div>
</td>
<td class="py-4 font-bold">
{{ subtotal|number_format(2, ',', ' ') }} €
</td>
<td class="py-4">
<button
class="text-red-500 hover:text-red-700"
hx-delete="/cart/remove/{{ product.id }}"
hx-target="#cart-item-{{ product.id }}"
hx-swap="outerHTML swap:0.3s"
hx-confirm="Supprimer cet article ?"
>
Supprimer
</button>
</td>
</tr>
$response = new Response($html);
$response->headers->set('HX-Trigger', 'cartUpdated');
return $response;
<div hx-trigger="cartUpdated from:body" hx-get="/cart/total" hx-target="#cart-total">
<!-- Met à jour le total quand cartUpdated est déclenché -->
</div>
$response->headers->set('HX-Trigger', json_encode([
'cartUpdated' => null,
'showToast' => ['message' => 'Produit ajouté !', 'type' => 'success'],
]));
document.body.addEventListener('showToast', function(event) {
const { message, type } = event.detail;
showToast(message, type);
});
// Dans un preprocess ou controller
$token = \Drupal::csrfToken()->get('tailstore_cart');
<meta name="csrf-token" content="{{ csrf_token }}">
<script>
document.body.addEventListener('htmx:configRequest', function(event) {
event.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
});
</script>
public function add(int $product_id, Request $request): Response {
$token = $request->headers->get('X-CSRF-Token');
if (!\Drupal::csrfToken()->validate($token, 'tailstore_cart')) {
throw new AccessDeniedHttpException('Invalid CSRF token');
}
// ... suite
}
/* Indicateur de chargement */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-request.htmx-indicator {
display: inline-block;
}
/* Opacity pendant le chargement */
.htmx-request {
opacity: 0.5;
pointer-events: none;
}
<span class="htmx-indicator">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
htmx.logAll();
<script src="https://unpkg.com/htmx.org/dist/ext/debug.js"></script>
<div hx-ext="debug">
<!-- Contenu avec debug -->
</div>
  • htmx installé et chargé
  • Bouton ajout panier fonctionnel
  • Mini-cart mis à jour dynamiquement
  • Mise à jour quantités en temps réel
  • Suppression avec confirmation
  • Token CSRF configuré
  • Indicateurs de chargement

htmx est intégré ! Finalisons avec Stripe Checkout.