(base): Añade componente Pager

Incluye soporte HTMX vía `sort_link()`/`hx_pager` para navegación sin
recarga.
This commit is contained in:
Manuel Cillero 2026-08-06 22:37:28 +02:00
parent e8bba0803d
commit 3b01894024
14 changed files with 881 additions and 31 deletions

View file

@ -137,11 +137,7 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
),
)
// Campo oculto (form::Hidden).
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-selections"),
)
.with_child(form::Hidden::field("origin", "form-selections"))
// Botones de acción.
.with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop(
PropsOp::add_classes(class::ButtonColor::solid(
@ -261,11 +257,7 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
),
)
// Campo oculto (form::Hidden).
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-text"),
)
.with_child(form::Hidden::field("origin", "form-text"))
// Botones de acción.
.with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop(
PropsOp::add_classes(class::ButtonColor::solid(
@ -415,11 +407,7 @@ fn form_lists() -> Form {
form
// Campo oculto (form::Hidden).
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-lists"),
)
.with_child(form::Hidden::field("origin", "form-lists"))
// Botones de acción.
.with_child(
Button::submit(L10n::t("btn_submit", &LOC)).with_prop(PropsOp::add_classes(

View file

@ -490,6 +490,13 @@ pub mod swap {
pub const INNER_HTML: &str = "innerHTML";
/// Reemplaza el elemento objetivo completo.
pub const OUTER_HTML: &str = "outerHTML";
/// Reemplaza el elemento objetivo completo y desplaza la vista al borde superior tras el
/// intercambio (combina [`OUTER_HTML`] con el modificador `scroll:top`).
///
/// Pensado para listados paginados/ordenables cuyo `hx-swap` sustituye un contenedor completo
/// (tabla + paginador) al variar el número de filas entre páginas. Sin este anclaje la posición
/// del scroll tras el intercambio queda desajustada.
pub const OUTER_HTML_SCROLL_TOP: &str = "outerHTML scroll:top";
/// Inserta la respuesta antes de la etiqueta de apertura del objetivo.
pub const BEFORE_BEGIN: &str = "beforebegin";
/// Inserta la respuesta al inicio del contenido del objetivo.

View file

@ -0,0 +1,32 @@
//! Soporte HTMX al componente [`Pager`].
//!
//! [`Pager`] no conoce HTMX ni ninguna otra librería de interactividad. Cada enlace de página ya
//! funciona como una petición normal, y el elemento `<nav>` que los envuelve expone su propio
//! [`Props`], accesible con [`with_prop()`], que admite cualquier atributo HTML por nombre y valor
//! mediante [`PropsOp::set()`].
//!
//! Para una navegación sin recarga basta con añadir `hx::BOOST` (más `hx::TARGET` y `hx::SWAP`) al
//! propio `<nav>`. HTMX convierte automáticamente en peticiones AJAX los enlaces de todas las
//! páginas, reutilizando el `href` que cada uno ya tiene resuelto, sin que [`Pager`] tenga que
//! generar ningún atributo `hx-*` por sí misma.
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_htmx::prelude::*;
//!
//! let pager = Pager::new()
//! .with_base_path("/admin/users")
//! .with_extra_query("sort", "username")
//! .with_current_page(2)
//! .with_items_per_page(20)
//! .with_total_items(97)
//! .with_prop(PropsOp::set(hx::BOOST, "true"))
//! .with_prop(PropsOp::set(hx::TARGET, "#user-table-wrapper"))
//! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML_SCROLL_TOP))
//! .with_prop(PropsOp::set(hx::PUSH_URL, "true"));
//! ```
//!
//! [`Pager`]: pagetop::base::component::Pager
//! [`Props`]: pagetop::html::Props
//! [`PropsOp::set()`]: pagetop::html::PropsOp::set
//! [`with_prop()`]: pagetop::base::component::Pager::with_prop

View file

@ -1,4 +1,17 @@
//! Soporte HTMX al componente [`Table`].
//!
//! [`Table`] no conoce HTMX ni ninguna otra librería de interactividad. En su lugar, cada pieza que
//! necesita comportamiento interactivo ([`table::Row`], [`table::Cell`], [`table::SortLink`])
//! expone su propio [`Props`] que admite cualquier atributo HTML por nombre y valor mediante
//! [`PropsOp::set()`], el mismo mecanismo que usa el resto de PageTop para adoptar HTMX sin
//! depender de él. Basta con encadenar `.with_prop(PropsOp::set(hx::GET, ...))` sobre el elemento
//! que corresponda.
//!
//! Las cabeceras ordenables ([`table::Column::with_sort`]) ya llevan resuelta la parte que no
//! depende de HTMX: el enlace (`<a href>`), la semántica accesible (`aria-sort`) y el indicador
//! visual de dirección (clases CSS). Sólo falta (si se quiere navegación sin recarga) añadir los
//! atributos `hx-*` con [`table::SortLink::with_prop()`], o usar directamente [`sort_link()`] para
//! no repetirlos en cada columna.
use pagetop::prelude::*;
@ -6,15 +19,13 @@ 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.
/// Construye un [`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
/// recarga debe añadir sus propios atributos `hx-*` usando [`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.
/// `href`, `hx-swap="outerHTML scroll:top"` 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.
@ -23,13 +34,12 @@ use crate::hx;
///
/// - `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.
/// normalmente el resultado de [`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).
/// actualmente por otra columna. Se traslada tal cual a [`SortLink::with_dir()`].
///
/// # Ejemplo
///
@ -56,6 +66,11 @@ use crate::hx;
/// .with_sort(hx_table::sort_link(href, "#user-table-wrapper", active))
/// # }
/// ```
///
/// [`SortLink`]: pagetop::base::component::table::SortLink
/// [`SortLink::with_prop()`]: pagetop::base::component::table::SortLink::with_prop
/// [`SortLink::with_dir()`]: pagetop::base::component::table::SortLink::with_dir
/// [`Context::route()`]: pagetop::core::component::Context::route
pub fn sort_link(
href: impl Into<RoutePath>,
target: impl AsRef<str>,
@ -69,6 +84,6 @@ pub fn sort_link(
.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::SWAP, hx::swap::OUTER_HTML_SCROLL_TOP))
.with_prop(PropsOp::set(hx::PUSH_URL, "true"))
}

View file

@ -92,6 +92,7 @@ use pagetop::prelude::*;
include_locales!(LOCALES_HTMX);
pub mod hx;
pub mod hx_pager;
pub mod hx_table;
pub mod request;
pub mod response;
@ -99,6 +100,7 @@ pub mod response;
/// Prelude de `pagetop-htmx`.
pub mod prelude {
pub use crate::hx;
pub use crate::hx_pager;
pub use crate::hx_table;
pub use crate::request::HtmxRequestExt;
pub use crate::response::HtmxResponse;

View file

@ -26,7 +26,7 @@ async fn sort_link_sets_the_four_fixed_htmx_attributes() {
assert!(html.contains(r#"hx-get="/admin/users""#));
assert!(html.contains(r##"hx-target="#user-table""##));
assert!(html.contains(r#"hx-swap="outerHTML""#));
assert!(html.contains(r#"hx-swap="outerHTML scroll:top""#));
assert!(html.contains(r#"hx-push-url="true""#));
}

View file

@ -25,5 +25,8 @@ pub use html::Html;
mod intro;
pub use intro::{Intro, IntroOpening};
mod pager;
pub use pager::{Pager, PagerVisibility};
mod poweredby;
pub use poweredby::PoweredBy;

View file

@ -16,6 +16,9 @@ use crate::prelude::*;
/// let token = form::Hidden::new()
/// .with_name("csrf_token")
/// .with_value("a1b2c3d4e5");
///
/// // Equivalente, cuando `name` y `value` se conocen de antemano:
/// let token = form::Hidden::field("csrf_token", "a1b2c3d4e5");
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. En el servidor se deserializa
@ -52,6 +55,13 @@ impl Component for Hidden {
}
impl Hidden {
/// Crea un campo oculto con nombre y valor (atributos `name` y `value`) ya establecidos.
///
/// Equivale a `Hidden::new().with_name(name).with_value(value)`.
pub fn field(name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
Self::default().with_name(name).with_value(value)
}
// **< Hidden BUILDER >*************************************************************************
/// Establece el nombre del campo oculto (atributo `name`).

422
src/base/component/pager.rs Normal file
View file

@ -0,0 +1,422 @@
use crate::prelude::*;
/// Define cuándo mostrar los botones de página anterior/siguiente o el formulario de salto a página
/// de [`Pager`].
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum PagerVisibility {
/// Nunca se muestra.
Never,
/// Siempre se muestra (en el caso de los botones, se desactivan si no procede).
Always,
/// Se muestra sólo cuando el número total de páginas supera al número de páginas que se
/// muestra en el paginador (ver la documentación de [`Pager`] para más detalle).
#[default]
Auto,
}
/// Componente para añadir un **paginador** a un listado.
///
/// `Pager` permite navegar por las páginas de un listado de ítems cuando supera el número máximo de
/// ítems admitidos por página. Resuelve el enlace para acceder a cada página del listado a partir
/// de una ruta base, un conjunto de parámetros de consulta adicionales (orden, búsqueda, etc.) y el
/// estado actual ([`current_page`](Self::current_page), [`items_per_page`](Self::items_per_page),
/// [`total_items`](Self::total_items)).
///
/// El componente se renderiza sólo si el listado requiere más de una página.
///
/// No pretende sustituir a [`Table`] ni imponer cómo se presenta un listado: sólo modela la
/// paginación en sí, normalmente presentada junto a `Table` dentro de un mismo contenedor.
///
/// El listado de páginas se flanquea con dos botones de navegación: página anterior y página
/// siguiente (las páginas primera y última ya están siempre disponibles como números, así que no
/// llevan un botón dedicado). Su visibilidad, junto a la del formulario de salto a página, se
/// controla con [`PagerVisibility`] a través de [`with_prev_next()`](Self::with_prev_next) y
/// [`with_jump()`](Self::with_jump): `Never` los oculta siempre, `Always` los muestra siempre (en
/// el caso de los botones, con el extremo correspondiente desactivado en vez de oculto), y `Auto`
/// -el valor por defecto de ambos- los muestra sólo cuando el número total de páginas supera al
/// número de páginas que se muestra en el paginador; en ese caso, si la página actual coincide con
/// un extremo, el botón correspondiente se muestra igualmente, pero desactivado.
///
/// Un elemento `<nav>` envuelve todo el paginador. Lleva un `aria-label` por defecto que puede
/// sustituirse con [`with_aria_label()`](Self::with_aria_label) por otro más específico, por
/// ejemplo cuando una misma página tiene varios paginadores.
///
/// # Acotando el número de ítems del paginador
///
/// Con listados largos, mostrar un número por cada página real puede desbordar la interfaz. Con
/// [`with_window()`](Self::with_window) se puede limitar el número de páginas que se muestran a
/// cada lado de la página actual. Por defecto vale `2` para no mostrar más de 9 celdas en total.
///
/// Si el valor de la ventana es mayor que `0`, `Pager` siempre muestra la primera y la última
/// página como números, más la ventana indicada antes y después de la página actual, sustituyendo
/// por una elipsis (`…`) cualquier tramo oculto de dos o más páginas. Si el tramo oculto es de una
/// sola página, se muestra directamente en vez de la elipsis, porque ocultarla no ahorra espacio.
///
/// Por ejemplo, con `with_window(3)`, página actual `34` y con `200` páginas en total, el paginador
/// se mostraría así:
///
/// ```text
/// | 1 | … | 31 | 32 | 33 | [34] | 35 | 36 | 37 | … | 200 |
/// ```
///
/// Cuando corresponda según [`jump()`](Self::jump), [`Pager`] puede añadir un pequeño formulario
/// para saltar directamente a una página escribiendo su número, sin depender de JavaScript. Un
/// único campo numérico (`min`/`max` según el total de páginas) y un botón de envío.
///
/// # Clases CSS
///
/// - `.pager` - clase base del componente (elemento `<nav>`).
/// - `.pagination` - clase del elemento `<ul>` que contiene los enlaces de página.
/// - `.page-item` - presente en todos los `<li>` del listado.
/// - `.page-link` - presente en todos los enlaces (`<a>`) del listado.
/// - `.page-link-icon` - envuelve el carácter (``/``) de los botones de navegación, para poder
/// ajustar su tamaño o posición sin afectar al área interactiva de `.page-link`.
/// - `.page-previous` / `.page-next` - añadidas a los `<li>` de página anterior/siguiente.
/// - `.page-ellipsis` - clase del `<li>` que representa un tramo de páginas ocultas.
/// - `.active` - añadida al `<li>` de la página actualmente visible.
/// - `.disabled` - añadida al `<li>` de los extremos cuando no procede navegar.
/// - `.pager-jump` - clase del `<form>` para saltar directamente a una página.
/// - `.pager-jump-input` - clase del campo numérico del formulario de salto.
/// - `.pager-jump-button` - clase del botón de envío del formulario de salto.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// // Listado de usuarios filtrado por búsqueda y ordenado por nombre: los parámetros que deben
/// // sobrevivir entre páginas se declaran con `with_extra_query()`, en el orden en que deben
/// // aparecer en la URL; `page` se añade siempre al final por `Pager`.
/// let pager = Pager::new()
/// .with_base_path("/admin/users")
/// .with_extra_query("q", "ana")
/// .with_extra_query("sort", "username")
/// .with_extra_query("dir", "asc")
/// .with_current_page(1)
/// .with_items_per_page(20)
/// .with_total_items(97);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Pager {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve la ruta base sobre la que se construye el enlace de cada página.
base_path: AttrValue,
/// Devuelve los parámetros de consulta adicionales que viajan en el enlace de cada página, en
/// el orden en que se añadieron.
extra_query: Vec<(String, String)>,
/// Devuelve la página actual (siendo 1 la primera página).
current_page: u64,
/// Devuelve el número de elementos que se muestran por página.
items_per_page: u64,
/// Devuelve el número total de elementos del listado completo.
total_items: u64,
/// Devuelve el número de páginas mostradas a cada lado de la página actual antes de truncar con
/// elipsis (predeterminado a `2` páginas a cada lado).
///
/// El valor `0` no trunca y muestra siempre todas las páginas, recomendable sólo para listados
/// con un número pequeño de páginas.
#[default(2)]
window: u64,
/// Devuelve la visibilidad de los botones de página anterior/siguiente.
prev_next: PagerVisibility,
/// Devuelve la visibilidad del formulario para saltar directamente a una página.
jump: PagerVisibility,
/// Devuelve la etiqueta de accesibilidad (`aria-label`) del elemento `<nav>`.
#[default(L10n::l("pager_aria_label"))]
aria_label: L10n,
}
// Elemento visible del listado de páginas: un número de página o una elipsis que resume un tramo
// de páginas ocultas.
enum PageItem {
Number(u64),
Ellipsis,
}
#[async_trait]
impl Component for Pager {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, cx: &Context) {
// Asegura un `id` propio si no está definido. El formulario de salto a página deriva sus
// identificadores de éste para no colisionar si hay varios paginadores en la misma página.
let id = cx.required_id::<Self>(self.id(), 1);
self.alter_prop(PropsOp::ensure_id(id));
self.alter_prop(PropsOp::prepend_classes("pager"));
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let total_pages = self.total_pages();
if total_pages <= 1 {
return Ok(html! {});
}
let page = self.current_page().clamp(1, total_pages);
let base_path = self.base_path().as_str().unwrap_or_default();
// Ruta común a los enlaces del paginador, con los parámetros de `extra_query` añadidos a
// `base_path`. Pasa por `cx.route()` para preservar el parámetro `lang` si corresponde.
let mut route = cx.route(base_path.to_owned());
for (key, value) in self.extra_query() {
route.alter_param(key, value);
}
let first_disabled = page <= 1;
let last_disabled = page >= total_pages;
let items = self.page_items(page, total_pages);
let truncated = items.iter().any(|item| matches!(item, PageItem::Ellipsis));
let show_prev_next = match self.prev_next() {
PagerVisibility::Never => false,
PagerVisibility::Always => true,
PagerVisibility::Auto => truncated,
};
let show_jump = match self.jump() {
PagerVisibility::Never => false,
PagerVisibility::Always => true,
PagerVisibility::Auto => truncated,
};
Ok(html! {
nav (self.props()) aria-label=(self.aria_label().using(cx)) {
ul.pagination {
@if show_prev_next {
li.page-item.page-previous.disabled[first_disabled] {
a.page-link
href=[(!first_disabled).then(|| Self::page_route(&route, page - 1))]
aria-disabled=[first_disabled.then_some("true")]
aria-label=(L10n::l("pager_previous_label").using(cx)) {
span.page-link-icon { "" }
}
}
}
@for item in items {
@match item {
PageItem::Number(n) => {
@let href = Self::page_route(&route, n);
li.page-item.active[n == page] {
a.page-link
href=(href)
aria-current=[(n == page).then_some("page")] {
(n.to_string())
}
}
}
PageItem::Ellipsis => {
li.page-item.page-ellipsis aria-hidden="true" { "" }
}
}
}
@if show_prev_next {
li.page-item.page-next.disabled[last_disabled] {
a.page-link
href=[(!last_disabled).then(|| Self::page_route(&route, page + 1))]
aria-disabled=[last_disabled.then_some("true")]
aria-label=(L10n::l("pager_next_label").using(cx)) {
span.page-link-icon { "" }
}
}
}
}
@if show_jump { ({
// En `setup()` se garantiza que el paginador ya tiene un `id`.
let id = self.id().unwrap();
// Construye el formulario para saltar directamente a una página.
let mut form = Form::new()
.with_id(util::join!(id, "-jump"))
.with_prop(PropsOp::add_classes("pager-jump"))
.with_method(form::Method::Get)
.with_action(base_path.to_owned());
// Un formulario `GET` descarta cualquier `query` que ya tuviera `action` y la
// sustituye por sus propios campos al enviarse, así que `extra_query` y `lang`
// no bastan para formar parte de la URL: viajan como campos ocultos.
for (key, val) in self.extra_query() {
form = form.with_child(form::Hidden::field(key, val));
}
if let Some(lang) = route.param("lang") {
form = form.with_child(form::Hidden::field("lang", lang));
}
form.with_child(
form::Number::new()
.with_id(util::join!(id, "-jump-page"))
.with_prop(PropsOp::add_classes("pager-jump-input"))
.with_name("page")
.with_min(Some(1))
.with_max(Some(total_pages))
.with_value(Some(page))
.with_label(L10n::l("pager_goto_label")),
)
.with_child(
Button::submit(L10n::l("pager_goto_button"))
.with_prop(PropsOp::add_classes("pager-jump-button")),
)
.render(cx).await
}) }
}
})
}
}
impl Pager {
// **< Pager BUILDER >*************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Establece la ruta base sobre la que se construye el enlace de cada página.
#[builder_fn]
pub fn with_base_path(mut self, base_path: impl AsRef<str>) -> Self {
self.base_path.alter_str(base_path);
self
}
/// Añade un parámetro de consulta que debe viajar en el enlace de cada página, además de
/// `page` (que `Pager` añade siempre al final). Llamar varias veces añade varios
/// parámetros, en el orden en que se declaren.
#[builder_fn]
pub fn with_extra_query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_query.push((key.into(), value.into()));
self
}
/// Establece la página actual (siendo 1 la primera página).
#[builder_fn]
pub fn with_current_page(mut self, current_page: u64) -> Self {
self.current_page = current_page;
self
}
/// Establece el número de elementos que se muestran por página.
#[builder_fn]
pub fn with_items_per_page(mut self, items_per_page: u64) -> Self {
self.items_per_page = items_per_page;
self
}
/// Establece el número total de elementos del listado completo.
#[builder_fn]
pub fn with_total_items(mut self, total_items: u64) -> Self {
self.total_items = total_items;
self
}
/// Establece el número de páginas mostradas a cada lado de la página actual antes de truncar
/// el listado con una elipsis. Por defecto es `2`.
///
/// El valor `0` desactiva el truncado y muestra siempre todas las páginas. Usar cuando el
/// número total de páginas sea pequeño y no haya riesgo de desbordar la interfaz.
#[builder_fn]
pub fn with_window(mut self, window: u64) -> Self {
self.window = window;
self
}
/// Establece la visibilidad de los botones de página anterior/siguiente. Por defecto es
/// `PagerVisibility::Auto`: sólo se muestran cuando el número total de páginas supera al
/// número de páginas que se muestra en el paginador (con el extremo correspondiente
/// desactivado en vez de oculto).
#[builder_fn]
pub fn with_prev_next(mut self, prev_next: PagerVisibility) -> Self {
self.prev_next = prev_next;
self
}
/// Establece la visibilidad del formulario para saltar directamente a una página. Por
/// defecto es `PagerVisibility::Auto`: sólo se muestra cuando el número total de páginas
/// supera al número de páginas que se muestra en el paginador.
#[builder_fn]
pub fn with_jump(mut self, jump: PagerVisibility) -> Self {
self.jump = jump;
self
}
/// Establece la etiqueta de accesibilidad (`aria-label`) del elemento `<nav>`. Por defecto es
/// "Page navigation" (clave `pager_aria_label`), igual que hace el paginador de Bootstrap.
#[builder_fn]
pub fn with_aria_label(mut self, aria_label: L10n) -> Self {
self.aria_label = aria_label;
self
}
// **< Pager HELPERS >*************************************************************************
/// Número total de páginas según [`total_items()`](Self::total_items) y
/// [`items_per_page()`](Self::items_per_page). Nunca es cero, aunque `total_items` sea cero:
/// devuelve `1` igualmente (es [`prepare()`](Component::prepare) quien decide no renderizar
/// nada cuando sólo hay una página).
pub fn total_pages(&self) -> u64 {
let items_per_page = self.items_per_page().max(1);
self.total_items().div_ceil(items_per_page).max(1)
}
// Calcula los elementos visibles del listado de páginas. Si `window` es `0`, o si el total de
// páginas ya cabe sin necesidad de truncar (ver más abajo), devuelve todas las páginas sin
// elipsis. En caso contrario, siempre incluye la primera y la última página, la ventana de
// páginas alrededor de la actual (`window` antes y después, recortada a los límites del
// listado), y sustituye por una elipsis cualquier tramo oculto de dos o más páginas -- un
// tramo de una sola página se muestra directamente, ya que ocultarla tras una elipsis no
// ahorra espacio.
fn page_items(&self, page: u64, total_pages: u64) -> Vec<PageItem> {
// Acotado a `total_pages`: una ventana mayor no aporta nada (ya se mostrarían todas las
// páginas) y evita operar con un valor arbitrariamente grande más abajo.
let window = self.window().min(total_pages);
// Con `total_pages <= 2 * window + 3` la ventana más los dos extremos ya cubren el listado
// completo en el caso más desfavorable (página actual centrada), así que truncar no
// ahorraría ningún número.
if window == 0 || total_pages <= window.saturating_mul(2).saturating_add(3) {
return (1..=total_pages).map(PageItem::Number).collect();
}
let low = page.saturating_sub(window).max(2);
let high = page.saturating_add(window).min(total_pages - 1);
let mut items = vec![PageItem::Number(1)];
match low {
2 => {}
3 => items.push(PageItem::Number(2)),
_ => items.push(PageItem::Ellipsis),
}
items.extend((low..=high).map(PageItem::Number));
match total_pages - high {
1 => {}
2 => items.push(PageItem::Number(total_pages - 1)),
_ => items.push(PageItem::Ellipsis),
}
items.push(PageItem::Number(total_pages));
items
}
// Construye el enlace a la página `page` añadiendo `page` sobre `route`.
fn page_route(route: &RoutePath, page: u64) -> String {
route
.clone()
.with_param("page", page.to_string())
.to_string()
}
}

View file

@ -73,9 +73,9 @@ impl RoutePath {
/// Un `value` vacío no se distingue de [`with_flag()`](Self::with_flag): ambos se renderizan
/// como `?key`, sin `=`.
#[builder_fn]
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
pub fn with_param(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
self.query
.insert(key.into(), Self::encode_query_value(&value.into()));
.insert(key.into(), Self::encode_query_value(value.as_ref()));
self
}
@ -91,6 +91,16 @@ impl RoutePath {
&self.path
}
/// Devuelve el valor de un parámetro de consulta ya almacenado, si existe.
///
/// El valor se devuelve tal como se almacenó, es decir, ya codificado según RFC 3986 (ver
/// [`with_param()`](Self::with_param)). Para valores que sólo usen caracteres sin reservar
/// (letras, dígitos, `-`, `_`, `.`, `~`), como un identificador de idioma BCP 47, no hay
/// diferencia con el valor original.
pub fn param(&self, key: impl AsRef<str>) -> Option<&str> {
self.query.get(key.as_ref()).map(String::as_str)
}
/// Indica si el *path* **parece** una URL externa por su prefijo (ver
/// [`util::url_looks_external()`](crate::util::url_looks_external)).
pub fn is_external(&self) -> bool {

View file

@ -99,9 +99,25 @@ impl SortDir {
}
}
/// Permite pasar un [`SortDir`] allí donde se espere `impl AsRef<str>`, por ejemplo, en el
/// parámetro `value` de [`RoutePath::with_param()`](crate::html::RoutePath::with_param) o su
/// equivalente `alter_param()`, sin tener que escribir `.as_str()` a mano.
///
/// ```rust
/// use pagetop::html::SortDir;
///
/// assert_eq!(SortDir::Asc.as_ref(), "asc");
/// assert_eq!(SortDir::Desc.as_ref(), "desc");
/// ```
impl AsRef<str> for SortDir {
fn as_ref(&self) -> &str {
self.as_str()
}
}
/// Permite pasar un [`SortDir`] allí donde se espere `impl Into<String>`, por ejemplo, en
/// [`RoutePath::with_param()`](crate::html::RoutePath::with_param) o su equivalente
/// `alter_param()`, sin tener que escribir `as_str().to_owned()` a mano.
/// [`Pager::with_extra_query()`](crate::base::component::Pager::with_extra_query), sin tener que
/// escribir `as_str().to_owned()` a mano.
///
/// ```rust
/// use pagetop::html::SortDir;

View file

@ -18,3 +18,10 @@ intro_have_fun = Coding is creating
# PoweredBy component.
poweredby_pagetop = Powered by { $pagetop_link }
# Pager component.
pager_aria_label = Page navigation
pager_previous_label = Previous page
pager_next_label = Next page
pager_goto_label = Go to page
pager_goto_button = Go

View file

@ -18,3 +18,10 @@ intro_have_fun = Programar es crear
# PoweredBy component.
poweredby_pagetop = Funciona con { $pagetop_link }
# Pager component.
pager_aria_label = Navegación de páginas
pager_previous_label = Página anterior
pager_next_label = Página siguiente
pager_goto_label = Ir a la página
pager_goto_button = Ir

331
tests/component_pager.rs Normal file
View file

@ -0,0 +1,331 @@
use pagetop::prelude::*;
#[pagetop::test]
async fn single_page_renders_nothing() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_items_per_page(20)
.with_total_items(5);
let html = pager.render(&mut Context::default()).await.into_string();
assert_eq!(html, "");
}
#[pagetop::test]
async fn default_aria_label_is_page_navigation() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(1)
.with_items_per_page(10)
.with_total_items(50);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"aria-label="Page navigation""#));
}
#[pagetop::test]
async fn with_aria_label_overrides_the_default() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(1)
.with_items_per_page(10)
.with_total_items(50)
.with_aria_label(L10n::n("Users pagination"));
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"aria-label="Users pagination""#));
assert!(!html.contains(r#"aria-label="Page navigation""#));
}
#[pagetop::test]
async fn renders_page_links_and_current_page() {
let mut pager = Pager::new()
.with_base_path("/admin/users")
.with_current_page(2)
.with_items_per_page(20)
.with_total_items(45);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"href="/admin/users?page=1""#));
assert!(html.contains(r#"href="/admin/users?page=2" aria-current="page""#));
assert!(html.contains(r#"href="/admin/users?page=3""#));
assert!(html.contains(r#"<li class="page-item active">"#));
// Not truncated (3 pages), so there are no previous/next buttons: just 3 page numbers.
assert_eq!(html.matches(r#"class="page-link""#).count(), 3);
}
#[pagetop::test]
async fn prev_next_are_hidden_by_default_when_not_truncated() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(1)
.with_items_per_page(10)
.with_total_items(50);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(!html.contains("page-previous"));
assert!(!html.contains("page-next"));
}
#[pagetop::test]
async fn prev_next_are_shown_by_default_when_truncated() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(10)
.with_items_per_page(1)
.with_total_items(20);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"class="page-item page-previous"><a"#));
assert!(html.contains(r#"class="page-item page-next"><a"#));
}
#[pagetop::test]
async fn previous_is_disabled_at_the_first_page_when_truncated() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(1)
.with_items_per_page(1)
.with_total_items(20);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<li class="page-item page-previous disabled">"#));
assert!(html.contains(r#"aria-disabled="true" aria-label="Previous page">"#));
assert!(html.contains(r#"class="page-item page-next"><a"#));
}
#[pagetop::test]
async fn next_is_disabled_at_the_last_page_when_truncated() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(20)
.with_items_per_page(1)
.with_total_items(20);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<li class="page-item page-next disabled">"#));
assert!(html.contains(r#"aria-disabled="true" aria-label="Next page">"#));
assert!(html.contains(r#"class="page-item page-previous"><a"#));
}
#[pagetop::test]
async fn prev_next_can_be_forced_to_always_show_even_when_not_truncated() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(3)
.with_items_per_page(10)
.with_total_items(50)
.with_prev_next(PagerVisibility::Always);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"class="page-item page-previous"><a"#));
assert!(html.contains(r#"class="page-item page-next"><a"#));
}
#[pagetop::test]
async fn prev_next_never_hides_them_even_when_truncated() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(10)
.with_items_per_page(1)
.with_total_items(20)
.with_prev_next(PagerVisibility::Never);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(!html.contains("page-previous"));
assert!(!html.contains("page-next"));
}
#[pagetop::test]
async fn first_and_last_icon_buttons_never_render() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(10)
.with_items_per_page(1)
.with_total_items(20)
.with_prev_next(PagerVisibility::Always)
.with_window(3);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(!html.contains("page-first"));
assert!(!html.contains("page-last"));
}
#[pagetop::test]
async fn jump_form_is_hidden_by_default_when_not_truncated() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(2)
.with_items_per_page(1)
.with_total_items(5);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(!html.contains("pager-jump"));
}
#[pagetop::test]
async fn jump_form_is_shown_by_default_when_truncated() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(10)
.with_items_per_page(1)
.with_total_items(20);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(
r#"<form action="/list" accept-charset="UTF-8" id="pager-1-jump" class="form pager-jump">"#
));
assert!(html.contains(r#"name="page""#));
assert!(html.contains(r#"min="1""#));
assert!(html.contains(r#"max="20""#));
}
#[pagetop::test]
async fn jump_form_can_be_forced_always_or_never() {
let mut always_shown = Pager::new()
.with_base_path("/list")
.with_current_page(2)
.with_items_per_page(1)
.with_total_items(5)
.with_jump(PagerVisibility::Always);
let html = always_shown
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains("pager-jump"));
let mut never_shown = Pager::new()
.with_base_path("/list")
.with_current_page(10)
.with_items_per_page(1)
.with_total_items(20)
.with_jump(PagerVisibility::Never);
let html = never_shown
.render(&mut Context::default())
.await
.into_string();
assert!(!html.contains("pager-jump"));
}
#[pagetop::test]
async fn extra_query_travels_as_hidden_fields_in_the_jump_form() {
let mut pager = Pager::new()
.with_base_path("/admin/users")
.with_extra_query("q", "ana")
.with_extra_query("sort", "username")
.with_current_page(1)
.with_items_per_page(20)
.with_total_items(97)
.with_jump(PagerVisibility::Always);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<input type="hidden" name="q" value="ana">"#));
assert!(html.contains(r#"<input type="hidden" name="sort" value="username">"#));
}
#[pagetop::test]
async fn without_a_window_every_page_number_is_shown() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(10)
.with_items_per_page(1)
.with_total_items(20)
.with_window(0);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(!html.contains("page-ellipsis"));
assert!(html.contains(r#"href="/list?page=1""#));
assert!(html.contains(r#"href="/list?page=20""#));
}
#[pagetop::test]
async fn window_truncates_far_pages_behind_an_ellipsis() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(10)
.with_items_per_page(1)
.with_total_items(20)
.with_window(3);
let html = pager.render(&mut Context::default()).await.into_string();
assert_eq!(html.matches("page-ellipsis").count(), 2);
for page in [7, 8, 9, 10, 11, 12, 13] {
assert!(
html.contains(&format!(r#"href="/list?page={page}""#)),
"expected page {page} to be visible"
);
}
for page in [2, 3, 4, 5, 6, 14, 15, 16, 17, 18, 19] {
assert!(
!html.contains(&format!(r#"href="/list?page={page}""#)),
"expected page {page} to be hidden behind an ellipsis"
);
}
assert!(html.contains(r#"href="/list?page=1""#));
assert!(html.contains(r#"href="/list?page=20""#));
}
#[pagetop::test]
async fn a_single_hidden_page_is_shown_instead_of_an_ellipsis() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(6)
.with_items_per_page(1)
.with_total_items(20)
.with_window(3);
let html = pager.render(&mut Context::default()).await.into_string();
// The window (3..=9) leaves a single gap on the low side (page 2):
// it is shown instead of being truncated with an ellipsis.
assert_eq!(html.matches("page-ellipsis").count(), 1);
assert!(html.contains(r#"href="/list?page=2""#));
}
#[pagetop::test]
async fn current_page_near_an_edge_does_not_panic_and_keeps_first_and_last() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(1)
.with_items_per_page(1)
.with_total_items(20)
.with_window(3);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"href="/list?page=1" aria-current="page""#));
assert!(html.contains(r#"href="/list?page=20""#));
}
#[pagetop::test]
async fn small_total_is_never_truncated_even_with_a_window() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(5)
.with_items_per_page(1)
.with_total_items(9) // total_pages == 2 * window + 3
.with_window(3);
let html = pager.render(&mut Context::default()).await.into_string();
assert!(!html.contains("page-ellipsis"));
for page in 1..=9 {
assert!(html.contains(&format!(r#"href="/list?page={page}""#)));
}
}