✨ (htmx): Añade integración con HTMX 2
Constantes `hx-*`, `HtmxRequestExt` y `HtmxResponse` cubren el ciclo completo: escribir atributos, leer la petición y construir la respuesta. La extensión Htmx inyecta el script automáticamente. Añade `IntoResponse` y `Response` al prelude de PageTop.
This commit is contained in:
parent
511149caa7
commit
38fd24453e
15 changed files with 1389 additions and 1 deletions
228
extensions/pagetop-htmx/src/response.rs
Normal file
228
extensions/pagetop-htmx/src/response.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//! Implementación de [`HtmxResponse`] e [`IntoResponse`](pagetop::web::IntoResponse) para HTMX.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
// **< HtmxResponse >*******************************************************************************
|
||||
|
||||
/// Generador de respuestas HTML parciales con cabeceras HTMX.
|
||||
///
|
||||
/// En una aplicación HTMX, los *handlers* del servidor devuelven con frecuencia fragmentos HTML
|
||||
/// parciales acompañados de cabeceras especiales que instruyen al cliente sobre qué hacer con la
|
||||
/// respuesta: actualizar la URL del historial, disparar eventos JavaScript, redirigir, etc.
|
||||
///
|
||||
/// Implementa [`IntoResponse`](pagetop::web::IntoResponse), por lo que puede devolverse
|
||||
/// directamente desde cualquier *handler*.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::{HtmxResponse, hx};
|
||||
///
|
||||
/// async fn add_item(request: HttpRequest) -> impl IntoResponse {
|
||||
/// let new_item = html! { li #item-42 { "New item" } };
|
||||
///
|
||||
/// HtmxResponse::new(new_item)
|
||||
/// .retarget("#list")
|
||||
/// .reswap(hx::swap::BEFORE_END)
|
||||
/// .push_url("/items")
|
||||
/// .trigger("itemAdded")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Respuestas de sólo cabeceras
|
||||
///
|
||||
/// Cuando la respuesta no lleva cuerpo HTML (por ejemplo, una redirección o un refresco), usa
|
||||
/// [`HtmxResponse::empty()`](Self::empty):
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::HtmxResponse;
|
||||
///
|
||||
/// async fn delete_item() -> impl IntoResponse {
|
||||
/// HtmxResponse::empty().redirect("/items")
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Construcción
|
||||
///
|
||||
/// - [`HtmxResponse::new(markup)`](Self::new), con el fragmento HTML.
|
||||
/// - [`HtmxResponse::empty()`](Self::empty), sin cuerpo, sólo cabeceras.
|
||||
///
|
||||
/// # Cabeceras disponibles
|
||||
///
|
||||
/// Los nombres de cabecera como constantes están en [`crate::hx::response`].
|
||||
///
|
||||
/// # Múltiples eventos en `trigger`
|
||||
///
|
||||
/// Para disparar varios eventos en una sola llamada, pasa una cadena con comas o un objeto JSON:
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::HtmxResponse;
|
||||
///
|
||||
/// // Dos eventos sin datos:
|
||||
/// HtmxResponse::empty().trigger("itemAdded, listUpdated");
|
||||
///
|
||||
/// // Evento con datos en JSON:
|
||||
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#);
|
||||
/// ```
|
||||
pub struct HtmxResponse {
|
||||
markup: Markup,
|
||||
headers: web::http::HeaderMap,
|
||||
}
|
||||
|
||||
impl HtmxResponse {
|
||||
/// Crea una respuesta con el fragmento HTML indicado.
|
||||
pub fn new(markup: Markup) -> Self {
|
||||
Self {
|
||||
markup,
|
||||
headers: web::http::HeaderMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea una respuesta sin cuerpo HTML, útil para respuestas de sólo cabeceras.
|
||||
pub fn empty() -> Self {
|
||||
Self::new(html! {})
|
||||
}
|
||||
|
||||
// **< HtmxResponse BUILDER >*******************************************************************
|
||||
|
||||
/// Hace que HTMX realice una navegación AJAX a la URL indicada sin recargar la página.
|
||||
///
|
||||
/// A diferencia de [`redirect()`](Self::redirect), la navegación usa HTMX y actualiza sólo el
|
||||
/// objetivo definido por el destino. Acepta una URL o un objeto JSON con claves `path`,
|
||||
/// `target`, `swap`, `select` y `values` para personalizar la navegación:
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::HtmxResponse;
|
||||
///
|
||||
/// // Navegación simple:
|
||||
/// HtmxResponse::empty().location("/items");
|
||||
///
|
||||
/// // Navegación con destino personalizado:
|
||||
/// HtmxResponse::empty()
|
||||
/// .location(r##"{"path": "/items", "target": "#content"}"##);
|
||||
/// ```
|
||||
pub fn location(self, url: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-location", url)
|
||||
}
|
||||
|
||||
/// Empuja la URL indicada al historial del navegador.
|
||||
///
|
||||
/// El usuario podrá navegar hacia atrás hasta esa URL. Usar `"false"` para desactivar el empuje
|
||||
/// aunque esté habilitado por el atributo `hx-push-url` del elemento.
|
||||
pub fn push_url(self, url: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-push-url", url)
|
||||
}
|
||||
|
||||
/// Reemplaza la URL actual en el historial sin añadir una nueva entrada.
|
||||
///
|
||||
/// Usar `"false"` para desactivar el reemplazo.
|
||||
pub fn replace_url(self, url: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-replace-url", url)
|
||||
}
|
||||
|
||||
/// Provoca una redirección completa del navegador a la URL indicada.
|
||||
///
|
||||
/// A diferencia de [`location()`](Self::location), esta redirección recarga la página por
|
||||
/// completo, como un `window.location.href = url` en JavaScript.
|
||||
pub fn redirect(self, url: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-redirect", url)
|
||||
}
|
||||
|
||||
/// Provoca una recarga completa de la página actual.
|
||||
///
|
||||
/// Equivale a `window.location.reload()` en JavaScript.
|
||||
pub fn refresh(self) -> Self {
|
||||
self.set_header(b"hx-refresh", "true")
|
||||
}
|
||||
|
||||
/// Anula el `hx-target` del elemento y redirige la respuesta al selector CSS indicado.
|
||||
///
|
||||
/// Útil cuando el servidor necesita actualizar un elemento distinto al que realizó la petición,
|
||||
/// sin modificar el HTML del cliente.
|
||||
pub fn retarget(self, selector: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-retarget", selector)
|
||||
}
|
||||
|
||||
/// Anula el `hx-swap` del elemento e impone la estrategia de sustitución indicada.
|
||||
///
|
||||
/// Acepta los mismos valores que el atributo `hx-swap`, incluidos modificadores (`swap:200ms`,
|
||||
/// `scroll:top`, ...). Los valores tipados están en [`crate::hx::swap`].
|
||||
pub fn reswap(self, strategy: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-reswap", strategy)
|
||||
}
|
||||
|
||||
/// Anula el `hx-select` del elemento y selecciona el fragmento CSS indicado de la respuesta
|
||||
/// para insertarlo en el objetivo.
|
||||
pub fn reselect(self, selector: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-reselect", selector)
|
||||
}
|
||||
|
||||
/// Dispara uno o varios eventos JavaScript en el cliente al completar la respuesta.
|
||||
///
|
||||
/// Los eventos se disparan inmediatamente tras procesar la respuesta. Para disparar eventos con
|
||||
/// datos o después de otras fases del ciclo HTMX, ver
|
||||
/// [`trigger_after_settle()`](Self::trigger_after_settle) y
|
||||
/// [`trigger_after_swap()`](Self::trigger_after_swap).
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::HtmxResponse;
|
||||
///
|
||||
/// // Evento simple:
|
||||
/// HtmxResponse::empty().trigger("itemAdded");
|
||||
///
|
||||
/// // Múltiples eventos sin datos:
|
||||
/// HtmxResponse::empty().trigger("itemAdded, listUpdated");
|
||||
///
|
||||
/// // Evento con datos en JSON:
|
||||
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42, "name": "Example"}}"#);
|
||||
/// ```
|
||||
pub fn trigger(self, event: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-trigger", event)
|
||||
}
|
||||
|
||||
/// Dispara eventos JavaScript después de que HTMX haya aplicado la respuesta al DOM y haya
|
||||
/// completado la fase de *settle* (animaciones CSS).
|
||||
///
|
||||
/// Acepta los mismos formatos que [`trigger()`](Self::trigger).
|
||||
pub fn trigger_after_settle(self, event: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-trigger-after-settle", event)
|
||||
}
|
||||
|
||||
/// Dispara eventos JavaScript después de que HTMX haya aplicado la respuesta al DOM, pero antes
|
||||
/// de la fase de *settle*.
|
||||
///
|
||||
/// Acepta los mismos formatos que [`trigger()`](Self::trigger).
|
||||
pub fn trigger_after_swap(self, event: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-trigger-after-swap", event)
|
||||
}
|
||||
|
||||
// Inserta o reemplaza una cabecera. Los nombres deben ser bytes ASCII en minúsculas.
|
||||
fn set_header(mut self, name: &[u8], value: impl Into<String>) -> Self {
|
||||
let value = value.into();
|
||||
if let (Ok(n), Ok(v)) = (
|
||||
web::http::HeaderName::from_bytes(name),
|
||||
web::http::HeaderValue::from_str(&value),
|
||||
) {
|
||||
self.headers.insert(n, v);
|
||||
} else {
|
||||
trace::warn!(value = %value, "HtmxResponse: invalid header value, header discarded");
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl web::IntoResponse for HtmxResponse {
|
||||
fn into_response(self) -> Response {
|
||||
let mut headers = self.headers;
|
||||
headers.insert(
|
||||
web::http::header::CONTENT_TYPE,
|
||||
web::http::HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
(headers, self.markup.into_string()).into_response()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue