pagetop/src/base/component/pager.rs
Manuel Cillero 12c14afbf6 🐛 (base): Correcciones de visualización de Pager
Ajusta el cálculo de la ventana de páginas cerca de los extremos,
convierte la elipsis en una celda `.page-link` real (antes texto suelto
sin borde/fondo), separa el texto visible del aria-label accesible en
los botones anterior/siguiente, añade `with_align()` y ajusta el ancho
del campo de salto al número de dígitos.
2026-08-07 15:28:58 +02:00

491 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}
/// Define la alineación horizontal de [`Pager`] dentro de su contenedor.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum PagerAlign {
/// Alineado al comienzo.
Start,
/// Centrado (comportamiento por defecto).
#[default]
Center,
/// Alineado al final.
End,
}
/// 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 por otro más específico usando [`with_aria_label()`](Self::with_aria_label), por
/// ejemplo cuando una misma página tiene varios paginadores.
///
/// La alineación horizontal del paginador dentro de este contenedor se controla con [`PagerAlign`]
/// a través de [`with_align()`](Self::with_align). Por defecto es [`PagerAlign::Center`].
///
/// # 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`, que limita la vista a `9` celdas en total
/// (sin contar los botones de navegación anterior/siguiente). En general, el número máximo de
/// celdas mostradas para un `window` dado es `2 * window + 5`.
///
/// 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 la página actual está cerca de los extremos, se ajustan las páginas numeradas para
/// mantener el número de celdas mostradas según el valor de `window`.
///
/// 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>`).
/// - `.pager-align-start` / `.pager-align-center` / `.pager-align-end` - según [`PagerAlign`].
/// - `.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, y también en el `<span>` de
/// la elipsis, para que comparta con ellos el aspecto de celda (borde, fondo, radio, margen).
/// - `.page-link-icon` - envuelve el texto de los botones de navegación anterior/siguiente.
/// - `.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, y también al de la
/// elipsis, ya que tampoco es interactiva.
/// - `.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 alineación horizontal del paginador dentro de su contenedor.
align: PagerAlign,
/// 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"));
self.alter_prop(PropsOp::add_classes(match self.align() {
PagerAlign::Start => "pager-align-start",
PagerAlign::Center => "pager-align-center",
PagerAlign::End => "pager-align-end",
}));
}
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_aria_label").using(cx)) {
span.page-link-icon { (L10n::l("pager_previous_label").using(cx)) }
}
}
}
@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.disabled aria-hidden="true" {
span.page-link { "" }
}
}
}
}
@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_aria_label").using(cx)) {
span.page-link-icon { (L10n::l("pager_next_label").using(cx)) }
}
}
}
}
@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));
}
// Info para ajustar el ancho del campo al número de dígitos de `total_pages`.
let jump_width = util::join!(&total_pages.to_string().len().to_string(), "ch");
form.with_child(
form::Number::new()
.with_id(util::join!(id, "-jump-page"))
.with_prop(PropsOp::add_classes("pager-jump-input"))
.with_prop(PropsOp::add_style("--pager-jump-width", jump_width))
.with_name("page")
.with_min(Some(1))
.with_max(Some(total_pages))
.with_value(Some(page)),
)
.with_child(
Button::submit(L10n::l("pager_goto_button"))
.with_prop(PropsOp::add_classes("pager-jump-button"))
.with_title(L10n::l("pager_goto_label")),
)
.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 alineación horizontal del paginador dentro de su contenedor. Por defecto es
/// [`PagerAlign::Center`].
#[builder_fn]
pub fn with_align(mut self, align: PagerAlign) -> Self {
self.align = align;
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();
}
// Ventana centrada en `page`, protegiendo las operaciones aritméticas con signo.
let mut low = page as i128 - window as i128;
let mut high = page as i128 + window as i128;
if low < 2 {
let overflow = 2 - low;
low += overflow;
high += overflow;
}
if high > total_pages as i128 - 1 {
let overflow = high - (total_pages as i128 - 1);
high -= overflow;
low -= overflow;
}
low = low.clamp(2, total_pages as i128 - 1);
high = high.clamp(2, total_pages as i128 - 1);
// Si la ventana toca la primera o la última página, ese lado no necesita elipsis ni número
// de relleno: el hueco que se ahorra se reinvierte ampliando la ventana por el otro lado.
if low == 2 {
high = (high + 1).min(total_pages as i128 - 1);
}
if high == total_pages as i128 - 1 {
low = (low - 1).max(2);
}
let low = low as u64;
let high = high as u64;
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()
}
}