(htmx): Añade soporte HTMX a tablas ordenables

- Nuevo `SortDir` en `pagetop::html` para representar direcciones de
  orden (asc/desc) y calcular la siguiente al pulsar una cabecera.
- `hx_table::sort_link()` construye el enlace de ordenación con los
  cuatro atributos `hx-*` fijos, reutilizable en cualquier tabla.
- `HtmxResponse` usa `RoutePath` en `location`/`push_url`/`replace_url`/
  `redirect` para preservar "lang"; `location_json()` se separa de
  `location()` para el caso de configuración JSON personalizada.
- Añade el módulo `prelude` y una batería de tests para hx, hx_table,
  request, response y extension.
This commit is contained in:
Manuel Cillero 2026-07-23 07:21:27 +02:00
parent 55159f6d8f
commit e7f2563967
14 changed files with 1176 additions and 46 deletions

View file

@ -24,11 +24,9 @@
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_htmx::hx;
//! use pagetop_htmx::prelude::*;
//!
//! let endpoint = "/api/items"; // Calculado en tiempo de ejecución.
//!
//! let props = Props::new(hx::GET, endpoint)
//! let props = Props::new(hx::GET, "/api/items")
//! .with_prop(PropsOp::set(hx::TARGET, "#list"))
//! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML));
//!
@ -45,7 +43,7 @@
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_htmx::hx;
//! use pagetop_htmx::prelude::*;
//!
//! #[derive(AutoDefault, Getters)]
//! pub struct MyButton {
@ -75,7 +73,7 @@
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_htmx::hx;
//! use pagetop_htmx::prelude::*;
//!
//! // Evento nativo del DOM: hx-on:click="..."
//! // Evento propio de HTMX: hx-on::after-swap="..."
@ -91,7 +89,7 @@
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// let props = Props::new(hx::GET, "/api/search")
/// .with_prop(PropsOp::set(hx::TARGET, "#results"));
/// ```
@ -113,7 +111,7 @@ pub const PATCH: &str = "hx-patch";
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// // Al eliminar un elemento, reemplazarlo con respuesta vacía borra el nodo del DOM.
/// let props = Props::new(hx::DELETE, "/api/item/42")
/// .with_prop(PropsOp::set(hx::TARGET, "closest li"))
@ -130,7 +128,7 @@ pub const DELETE: &str = "hx-delete";
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// let props = Props::new(hx::GET, "/api/detalles")
/// .with_prop(PropsOp::set(hx::TARGET, "closest article"));
/// ```
@ -144,7 +142,7 @@ pub const TARGET: &str = "hx-target";
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// // Reemplaza el elemento completo con una transición de 300 ms.
/// let props = Props::new(hx::SWAP, "outerHTML swap:300ms");
/// // O usando la constante tipada más los modificadores:
@ -176,7 +174,7 @@ pub const SELECT_OOB: &str = "hx-select-oob";
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// // Buscar mientras se escribe, con 400 ms de espera y sólo si el valor cambia:
/// let props = Props::new(hx::GET, "/api/search")
/// .with_prop(PropsOp::set(hx::TRIGGER, "keyup changed delay:400ms"))
@ -294,6 +292,11 @@ pub const PRESERVE: &str = "hx-preserve";
/// - `"sse"` - soporte Server-Sent Events.
/// - `"json-enc"` - codifica la petición como JSON en lugar de form-urlencoded.
/// - `"loading-states"` - gestión avanzada de estados de carga.
///
/// `pagetop-htmx` sólo integra el *core* de HTMX: usar cualquiera de estas extensiones (ver el
/// [catálogo oficial](https://htmx.org/extensions/)) requiere añadir su script correspondiente por
/// separado, por ejemplo con [`JavaScript::defer()`](pagetop::html::JavaScript::defer) en
/// [`dependencies()`](pagetop::core::extension::Extension::dependencies).
pub const EXT: &str = "hx-ext";
/// Atributos HTMX que los elementos descendientes NO heredarán de este elemento.
@ -348,7 +351,7 @@ pub const DISABLE: &str = "hx-disable";
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// let props = Props::new(hx::on("click"), "this.classList.toggle('active')")
/// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'"));
/// ```
@ -364,7 +367,7 @@ pub fn on(event: &str) -> String {
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')")
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
/// ```
@ -382,7 +385,7 @@ pub fn on_htmx(event: &str) -> String {
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::hx;
/// use pagetop_htmx::prelude::*;
///
/// async fn handler(request: HttpRequest) {
/// if let Some(target) = request.headers().get(hx::request::TARGET) {
@ -417,18 +420,19 @@ pub mod request {
/// manualmente, aunque lo habitual es usar el constructor [`HtmxResponse`](crate::HtmxResponse).
///
/// ```rust,no_run
/// use pagetop_htmx::hx;
/// use pagetop::web::http::{HeaderMap, HeaderName, HeaderValue};
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// let mut headers = HeaderMap::new();
/// let mut headers = web::http::HeaderMap::new();
/// headers.insert(
/// hx::response::TRIGGER.parse::<HeaderName>().unwrap(),
/// HeaderValue::from_static("itemAdded"),
/// hx::response::TRIGGER.parse::<web::http::HeaderName>().unwrap(),
/// web::http::HeaderValue::from_static("itemAdded"),
/// );
/// ```
pub mod response {
/// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver
/// [`HtmxResponse::location()`](crate::HtmxResponse::location).
/// [`HtmxResponse::location()`](crate::HtmxResponse::location) y
/// [`HtmxResponse::location_json()`](crate::HtmxResponse::location_json).
pub const LOCATION: &str = "HX-Location";
/// Empuja la URL indicada al historial del navegador. Ver
/// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url).
@ -475,7 +479,7 @@ pub mod response {
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// // Reemplaza el elemento con una transición de 200 ms y desplaza al inicio:
/// let props = Props::new(hx::SWAP, format!("{} swap:200ms scroll:top", hx::swap::OUTER_HTML));
/// ```
@ -515,7 +519,7 @@ pub mod swap {
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_htmx::hx;
/// # use pagetop_htmx::prelude::*;
/// // Búsqueda progresiva: petición 400 ms después de que el usuario deje de escribir.
/// let search = Props::new(hx::TRIGGER, "keyup changed delay:400ms");
///

View file

@ -0,0 +1,74 @@
//! Soporte HTMX al componente [`Table`].
use pagetop::prelude::*;
use crate::hx;
// **< sort_link() >********************************************************************************
/// Construye un [`SortLink`](pagetop::base::component::table::SortLink) para actualizar el orden de
/// la tabla sin recargar la página.
///
/// [`Table`] y `SortLink` no requieren HTMX. Cada extensión que quiera aplicar una navegación sin
/// recarga debe añadir sus propios atributos `hx-*` usando
/// [`SortLink::with_prop()`](pagetop::base::component::table::SortLink::with_prop). Como esos
/// cuatro atributos son siempre los mismos para cualquier cabecera ordenable (`hx-get` igual al
/// `href`, `hx-swap="outerHTML"` y `hx-push-url="true"`, y sólo `hx-target` cambia según la tabla),
/// [`sort_link()`] evita reescribirlos en cada columna de cada listado.
///
/// El enlace resultante funciona igual con o sin HTMX: `href` es siempre la URL real del nuevo
/// estado de orden, así que navega correctamente aunque HTMX no esté disponible en el cliente.
///
/// # Argumentos
///
/// - `href`: URL completa hacia el nuevo estado de orden, reflejando ya el campo y la dirección
/// que resultarán de pulsar esta cabecera. Acepta cualquier tipo convertible a [`RoutePath`],
/// normalmente el resultado de [`Context::route()`](pagetop::core::component::Context::route),
/// para que el enlace preserve el parámetro `lang` cuando corresponda.
/// - `target`: selector CSS del elemento que HTMX debe reemplazar (`hx-target`), típicamente el
/// contenedor que envuelve la tabla completa.
/// - `dir`: dirección de orden vigente de esta columna, o `None` si la tabla está ordenada
/// actualmente por otra columna. Se traslada tal cual a
/// [`SortLink::with_dir()`](pagetop::base::component::table::SortLink::with_dir).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// # fn build_column(cx: &Context) -> table::Column {
/// let current_field = "username"; // Estado vigente de la tabla.
/// let current_dir = html::SortDir::Asc; // Ordenada por "username" en ascendente.
///
/// let field = "username"; // Cabecera de la propia columna "username".
/// let is_active = field == current_field; // En el ejemplo, coincide con el campo vigente.
/// let active = is_active.then_some(current_dir); // `Some` sólo si esta columna ordena ahora.
/// let next_dir = html::SortDir::next_for(active); // El siguiente clic alterna la dirección.
///
/// // `cx` es el `Context` de la petición en curso.
/// let href = cx
/// .route("/admin/users")
/// .with_param("sort", field)
/// .with_param("dir", next_dir);
///
/// table::Column::new(L10n::n("User"))
/// .with_sort(hx_table::sort_link(href, "#user-table-wrapper", active))
/// # }
/// ```
pub fn sort_link(
href: impl Into<RoutePath>,
target: impl AsRef<str>,
dir: impl Into<Option<SortDir>>,
) -> table::SortLink {
// Se materializa como `String` propio porque el mismo valor sirve para dos llamadas: como
// `RoutePath` en `SortLink::new()` (vía `href.as_str()`) y como `CowStr` en `PropsOp::set()`.
let href = href.into().to_string();
let target = target.as_ref().to_owned();
table::SortLink::new(href.as_ref())
.with_dir(dir)
.with_prop(PropsOp::set(hx::GET, href))
.with_prop(PropsOp::set(hx::TARGET, target))
.with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML))
.with_prop(PropsOp::set(hx::PUSH_URL, "true"))
}

View file

@ -64,11 +64,35 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
.render().await
}
```
Cuando los valores se construyen en tiempo de ejecución o quieres que una extensión aplique estos
atributos sin que el componente dependa de HTMX, usa `Props` junto con las constantes de `hx` en
lugar de escribirlos como literales en `html!`:
```rust
use pagetop::prelude::*;
use pagetop_htmx::prelude::*;
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
let props = Props::new(hx::GET, "/api/hello")
.with_prop(PropsOp::set(hx::TARGET, "#result"));
Page::new(request)
.with_child(Html::with(move |_| html! {
button (props) { "Say hello" }
div #result {}
}))
.render().await
}
```
*/
use pagetop::prelude::*;
include_locales!(LOCALES_HTMX);
pub mod hx;
pub mod hx_table;
mod request;
pub use request::HtmxRequestExt;
@ -76,7 +100,14 @@ pub use request::HtmxRequestExt;
mod response;
pub use response::HtmxResponse;
include_locales!(LOCALES_HTMX);
/// Prelude de `pagetop-htmx`.
pub mod prelude {
pub use crate::hx;
pub use crate::hx_table;
pub use crate::request::HtmxRequestExt;
pub use crate::response::HtmxResponse;
}
/// Integra HTMX 2 en cualquier aplicación PageTop.
///

View file

@ -16,7 +16,7 @@ use pagetop::prelude::*;
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::{HtmxRequestExt, HtmxResponse};
/// use pagetop_htmx::prelude::*;
///
/// async fn list_items(request: HttpRequest) -> Response {
/// if request.is_htmx() {

View file

@ -17,7 +17,7 @@ use pagetop::prelude::*;
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::{HtmxResponse, hx};
/// use pagetop_htmx::prelude::*;
///
/// async fn add_item(request: HttpRequest) -> impl IntoResponse {
/// let new_item = html! { li #item-42 { "New item" } };
@ -37,7 +37,7 @@ use pagetop::prelude::*;
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::HtmxResponse;
/// use pagetop_htmx::prelude::*;
///
/// async fn delete_item() -> impl IntoResponse {
/// HtmxResponse::empty().redirect("/items")
@ -59,7 +59,7 @@ use pagetop::prelude::*;
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::HtmxResponse;
/// use pagetop_htmx::prelude::*;
///
/// // Dos eventos sin datos:
/// HtmxResponse::empty().trigger("itemAdded, listUpdated");
@ -67,6 +67,7 @@ use pagetop::prelude::*;
/// // Evento con datos en JSON:
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#);
/// ```
#[must_use]
pub struct HtmxResponse {
markup: Markup,
headers: web::http::HeaderMap,
@ -91,45 +92,123 @@ impl HtmxResponse {
/// 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:
/// objetivo definido por el destino. Para personalizar `target`, `swap`, `select` o `values`,
/// usa [`location_json()`](Self::location_json).
///
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal
/// para que la URL preserve el parámetro `lang` cuando corresponda:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::HtmxResponse;
/// use pagetop_htmx::prelude::*;
///
/// // Navegación simple:
/// HtmxResponse::empty().location("/items");
///
/// // Navegación con destino personalizado:
/// HtmxResponse::empty()
/// .location(r##"{"path": "/items", "target": "#content"}"##);
/// # fn build_response(cx: &Context) -> HtmxResponse {
/// HtmxResponse::empty().location(cx.route("/items"))
/// # }
/// ```
pub fn location(self, url: impl Into<String>) -> Self {
self.set_header(b"hx-location", url)
pub fn location(self, url: impl Into<RoutePath>) -> Self {
self.set_header(b"hx-location", url.into().to_string())
}
/// Hace que HTMX realice una navegación AJAX personalizada, con un objeto JSON de configuración
/// en lugar de una URL simple.
///
/// Acepta un objeto JSON con las claves `path`, `target`, `swap`, `select` y `values` (ver la
/// [documentación de HTMX](https://htmx.org/reference/#response_headers) para el detalle de
/// cada una). Al no ser una URL, no admite `Context::route()`: si `path` necesita el parámetro
/// `lang`, hay que componerlo a mano antes de construir el JSON. Para una navegación simple sin
/// estas opciones, usa [`location()`](Self::location).
///
/// Si `json` no es sintácticamente válido, la cabecera se descarta y se registra un aviso; el
/// resto de la respuesta no se ve afectado. Esta comprobación sólo valida la sintaxis JSON, no
/// que las claves sean las que espera HTMX.
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// HtmxResponse::empty()
/// .location_json(r##"{"path": "/items", "target": "#content"}"##);
/// ```
///
/// Si algún valor se calcula en tiempo de ejecución, constrúyelo con [`serde_json::json!`] en
/// lugar de interpolarlo a mano con `format!()`: evita comillas u otros caracteres sin escapar
/// que romperían la estructura del JSON.
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// let item_name = "Alice's item"; // Contiene una comilla: no es seguro interpolarlo a mano.
///
/// let json = serde_json::json!({
/// "path": "/items",
/// "values": { "name": item_name },
/// })
/// .to_string();
///
/// HtmxResponse::empty().location_json(json);
/// ```
pub fn location_json(self, json: impl Into<String>) -> Self {
let json = json.into();
if let Err(error) = serde_json::from_str::<serde_json::Value>(&json) {
trace::warn!(
json = %json,
%error,
"HtmxResponse: invalid JSON in location_json(), header discarded",
);
return self;
}
self.set_header(b"hx-location", json)
}
/// 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)
///
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal
/// para que la URL preserve el parámetro `lang` cuando corresponda:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// # fn build_response(cx: &Context) -> HtmxResponse {
/// HtmxResponse::empty().push_url(cx.route("/items"))
/// # }
/// ```
pub fn push_url(self, url: impl Into<RoutePath>) -> Self {
self.set_header(b"hx-push-url", url.into().to_string())
}
/// 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)
/// Usar `"false"` para desactivar el reemplazo. Usa
/// [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal para
/// que la URL preserve el parámetro `lang` cuando corresponda.
pub fn replace_url(self, url: impl Into<RoutePath>) -> Self {
self.set_header(b"hx-replace-url", url.into().to_string())
}
/// 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)
///
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal
/// para que la URL preserve el parámetro `lang` cuando corresponda:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// # fn build_response(cx: &Context) -> HtmxResponse {
/// HtmxResponse::empty().redirect(cx.route("/items"))
/// # }
/// ```
pub fn redirect(self, url: impl Into<RoutePath>) -> Self {
self.set_header(b"hx-redirect", url.into().to_string())
}
/// Provoca una recarga completa de la página actual.
@ -170,7 +249,7 @@ impl HtmxResponse {
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::HtmxResponse;
/// use pagetop_htmx::prelude::*;
///
/// // Evento simple:
/// HtmxResponse::empty().trigger("itemAdded");
@ -181,6 +260,21 @@ impl HtmxResponse {
/// // Evento con datos en JSON:
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42, "name": "Example"}}"#);
/// ```
///
/// Si el dato del evento se calcula en tiempo de ejecución, constrúyelo con
/// [`serde_json::json!`] en lugar de interpolarlo a mano con `format!()`, para evitar comillas
/// u otros caracteres sin escapar que romperían la estructura del JSON:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_htmx::prelude::*;
///
/// let item_name = "Alice's item"; // Contiene una comilla: no es seguro interpolarlo a mano.
///
/// let json = serde_json::json!({ "itemAdded": { "name": item_name } }).to_string();
///
/// HtmxResponse::empty().trigger(json);
/// ```
pub fn trigger(self, event: impl Into<String>) -> Self {
self.set_header(b"hx-trigger", event)
}