🐛 (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.
This commit is contained in:
Manuel Cillero 2026-08-07 15:28:58 +02:00
parent 3b01894024
commit 12c14afbf6
6 changed files with 349 additions and 22 deletions

View file

@ -137,6 +137,128 @@ input:disabled + label {
color: var(--val-color--text--muted); color: var(--val-color--text--muted);
} }
/*
* Pager component
*/
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin: 1rem 0;
padding: 0 1rem;
}
.pager-align-start {
justify-content: flex-start;
}
.pager-align-center {
justify-content: center;
}
.pager-align-end {
justify-content: flex-end;
}
.pagination {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.pagination a {
display: flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
color: var(--val-color--text);
text-decoration: none;
}
.pagination a:hover {
background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
}
.pagination .active a {
color: #fff;
background-color: var(--val-color--primary);
}
.pagination .disabled a {
color: var(--val-color--text--muted);
pointer-events: none;
cursor: default;
}
.page-ellipsis {
padding: 0.375rem 0.25rem;
color: var(--val-color--text--muted);
}
/* .page-link-icon muestra el texto de navegación de los botones anterior/siguiente. El texto
accesible completo va aparte, en el aria-label del enlace. Por eso sustituir el texto visible por
otro contenido no afecta a la accesibilidad, p.ej. para usar ""/"" se puede aplicar:
.page-link-icon {
display: inline-block;
font-size: 0;
}
.page-previous .page-link-icon::before {
content: "";
font-size: 1rem;
display: inline-block;
transform: translateY(-0.15em) scale(1.5);
}
.page-next .page-link-icon::before {
content: "";
font-size: 1rem;
display: inline-block;
transform: translateY(-0.15em) scale(1.5);
}
*/
.pager-jump {
display: flex;
align-items: center;
}
.pager-jump:focus-within {
border-radius: 0.375rem;
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--val-color--primary) 25%, transparent);
}
.pager-jump-input > input.form-control:focus,
.pager-jump-button:focus {
outline: none;
box-shadow: none;
}
.pager-jump-input > input.form-control {
width: calc(var(--pager-jump-width, 1ch) + 2.5rem);
padding: 0.375rem 0.625rem;
line-height: var(--val-lh--base);
border: 0;
border-start-end-radius: 0;
border-end-end-radius: 0;
background-color: var(--val-color--light);
}
.pager-jump-input > input.form-control {
text-align: center;
appearance: textfield;
-moz-appearance: textfield;
}
.pager-jump-input > input.form-control::-webkit-outer-spin-button,
.pager-jump-input > input.form-control::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.pager-jump-button {
padding: 0.375rem 0.625rem;
border: 0;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.pager-jump-button:hover {
background-color: color-mix(in srgb, var(--val-color--primary) 85%, black);
}
/* /*
* Region Footer * Region Footer
*/ */

View file

@ -75,6 +75,8 @@ pub struct Button {
value: AttrValue, value: AttrValue,
/// Devuelve la etiqueta del botón. /// Devuelve la etiqueta del botón.
label: Attr<L10n>, label: Attr<L10n>,
/// Devuelve el texto emergente del botón (atributo `title`).
title: Attr<L10n>,
/// Devuelve si el botón recibe el foco automáticamente al cargar la página. /// Devuelve si el botón recibe el foco automáticamente al cargar la página.
autofocus: bool, autofocus: bool,
/// Devuelve si el botón está deshabilitado. /// Devuelve si el botón está deshabilitado.
@ -102,6 +104,7 @@ impl Component for Button {
(self.props()) (self.props())
name=[self.name().get()] name=[self.name().get()]
value=[self.value().get()] value=[self.value().get()]
title=[self.title().lookup(cx)]
autofocus[*self.autofocus()] autofocus[*self.autofocus()]
disabled[*self.disabled()] disabled[*self.disabled()]
{ {
@ -192,6 +195,13 @@ impl Button {
self self
} }
/// Establece o elimina el texto emergente del botón (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_title(mut self, title: impl Into<Option<L10n>>) -> Self {
self.title.alter_opt(title.into());
self
}
/// Establece si el botón recibe el foco automáticamente al cargar la página. /// Establece si el botón recibe el foco automáticamente al cargar la página.
#[builder_fn] #[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self { pub fn with_autofocus(mut self, autofocus: bool) -> Self {

View file

@ -14,6 +14,18 @@ pub enum PagerVisibility {
Auto, 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. /// 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 /// `Pager` permite navegar por las páginas de un listado de ítems cuando supera el número máximo de
@ -38,14 +50,19 @@ pub enum PagerVisibility {
/// un extremo, el botón correspondiente se muestra igualmente, pero desactivado. /// 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 /// 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 /// 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. /// 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 /// # 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 /// 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 /// [`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. /// 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 /// 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 /// página como números, más la ventana indicada antes y después de la página actual, sustituyendo
@ -59,6 +76,9 @@ pub enum PagerVisibility {
/// | 1 | … | 31 | 32 | 33 | [34] | 35 | 36 | 37 | … | 200 | /// | 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 /// 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 /// 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. /// único campo numérico (`min`/`max` según el total de páginas) y un botón de envío.
@ -66,15 +86,17 @@ pub enum PagerVisibility {
/// # Clases CSS /// # Clases CSS
/// ///
/// - `.pager` - clase base del componente (elemento `<nav>`). /// - `.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. /// - `.pagination` - clase del elemento `<ul>` que contiene los enlaces de página.
/// - `.page-item` - presente en todos los `<li>` del listado. /// - `.page-item` - presente en todos los `<li>` del listado.
/// - `.page-link` - presente en todos los enlaces (`<a>`) del listado. /// - `.page-link` - presente en todos los enlaces (`<a>`) del listado, y también en el `<span>` de
/// - `.page-link-icon` - envuelve el carácter (``/``) de los botones de navegación, para poder /// la elipsis, para que comparta con ellos el aspecto de celda (borde, fondo, radio, margen).
/// ajustar su tamaño o posición sin afectar al área interactiva de `.page-link`. /// - `.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-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. /// - `.page-ellipsis` - clase del `<li>` que representa un tramo de páginas ocultas.
/// - `.active` - añadida al `<li>` de la página actualmente visible. /// - `.active` - añadida al `<li>` de la página actualmente visible.
/// - `.disabled` - añadida al `<li>` de los extremos cuando no procede navegar. /// - `.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` - clase del `<form>` para saltar directamente a una página.
/// - `.pager-jump-input` - clase del campo numérico del formulario de salto. /// - `.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. /// - `.pager-jump-button` - clase del botón de envío del formulario de salto.
@ -118,6 +140,8 @@ pub struct Pager {
/// con un número pequeño de páginas. /// con un número pequeño de páginas.
#[default(2)] #[default(2)]
window: u64, 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. /// Devuelve la visibilidad de los botones de página anterior/siguiente.
prev_next: PagerVisibility, prev_next: PagerVisibility,
/// Devuelve la visibilidad del formulario para saltar directamente a una página. /// Devuelve la visibilidad del formulario para saltar directamente a una página.
@ -150,6 +174,11 @@ impl Component for Pager {
let id = cx.required_id::<Self>(self.id(), 1); let id = cx.required_id::<Self>(self.id(), 1);
self.alter_prop(PropsOp::ensure_id(id)); self.alter_prop(PropsOp::ensure_id(id));
self.alter_prop(PropsOp::prepend_classes("pager")); 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> { async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
@ -192,8 +221,8 @@ impl Component for Pager {
a.page-link a.page-link
href=[(!first_disabled).then(|| Self::page_route(&route, page - 1))] href=[(!first_disabled).then(|| Self::page_route(&route, page - 1))]
aria-disabled=[first_disabled.then_some("true")] aria-disabled=[first_disabled.then_some("true")]
aria-label=(L10n::l("pager_previous_label").using(cx)) { aria-label=(L10n::l("pager_previous_aria_label").using(cx)) {
span.page-link-icon { "" } span.page-link-icon { (L10n::l("pager_previous_label").using(cx)) }
} }
} }
} }
@ -210,7 +239,9 @@ impl Component for Pager {
} }
} }
PageItem::Ellipsis => { PageItem::Ellipsis => {
li.page-item.page-ellipsis aria-hidden="true" { "" } li.page-item.page-ellipsis.disabled aria-hidden="true" {
span.page-link { "" }
}
} }
} }
} }
@ -219,8 +250,8 @@ impl Component for Pager {
a.page-link a.page-link
href=[(!last_disabled).then(|| Self::page_route(&route, page + 1))] href=[(!last_disabled).then(|| Self::page_route(&route, page + 1))]
aria-disabled=[last_disabled.then_some("true")] aria-disabled=[last_disabled.then_some("true")]
aria-label=(L10n::l("pager_next_label").using(cx)) { aria-label=(L10n::l("pager_next_aria_label").using(cx)) {
span.page-link-icon { "" } span.page-link-icon { (L10n::l("pager_next_label").using(cx)) }
} }
} }
} }
@ -246,19 +277,23 @@ impl Component for Pager {
form = form.with_child(form::Hidden::field("lang", 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.with_child(
form::Number::new() form::Number::new()
.with_id(util::join!(id, "-jump-page")) .with_id(util::join!(id, "-jump-page"))
.with_prop(PropsOp::add_classes("pager-jump-input")) .with_prop(PropsOp::add_classes("pager-jump-input"))
.with_prop(PropsOp::add_style("--pager-jump-width", jump_width))
.with_name("page") .with_name("page")
.with_min(Some(1)) .with_min(Some(1))
.with_max(Some(total_pages)) .with_max(Some(total_pages))
.with_value(Some(page)) .with_value(Some(page)),
.with_label(L10n::l("pager_goto_label")),
) )
.with_child( .with_child(
Button::submit(L10n::l("pager_goto_button")) Button::submit(L10n::l("pager_goto_button"))
.with_prop(PropsOp::add_classes("pager-jump-button")), .with_prop(PropsOp::add_classes("pager-jump-button"))
.with_title(L10n::l("pager_goto_label")),
) )
.render(cx).await .render(cx).await
}) } }) }
@ -332,6 +367,14 @@ impl Pager {
self 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 /// 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 /// `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 /// número de páginas que se muestra en el paginador (con el extremo correspondiente
@ -389,8 +432,34 @@ impl Pager {
return (1..=total_pages).map(PageItem::Number).collect(); return (1..=total_pages).map(PageItem::Number).collect();
} }
let low = page.saturating_sub(window).max(2); // Ventana centrada en `page`, protegiendo las operaciones aritméticas con signo.
let high = page.saturating_add(window).min(total_pages - 1); 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)]; let mut items = vec![PageItem::Number(1)];

View file

@ -21,7 +21,9 @@ poweredby_pagetop = Powered by { $pagetop_link }
# Pager component. # Pager component.
pager_aria_label = Page navigation pager_aria_label = Page navigation
pager_previous_label = Previous page pager_previous_label = Previous
pager_next_label = Next page pager_previous_aria_label = Previous page
pager_goto_label = Go to page pager_next_label = Next
pager_next_aria_label = Next page
pager_goto_label = Jump to page
pager_goto_button = Go pager_goto_button = Go

View file

@ -21,7 +21,9 @@ poweredby_pagetop = Funciona con { $pagetop_link }
# Pager component. # Pager component.
pager_aria_label = Navegación de páginas pager_aria_label = Navegación de páginas
pager_previous_label = Página anterior pager_previous_label = Anterior
pager_next_label = Página siguiente pager_previous_aria_label = Página anterior
pager_goto_label = Ir a la página pager_next_label = Siguiente
pager_next_aria_label = Página siguiente
pager_goto_label = Saltar a la página
pager_goto_button = Ir pager_goto_button = Ir

View file

@ -313,6 +313,128 @@ async fn current_page_near_an_edge_does_not_panic_and_keeps_first_and_last() {
assert!(html.contains(r#"href="/list?page=20""#)); assert!(html.contains(r#"href="/list?page=20""#));
} }
#[pagetop::test]
async fn window_max_cell_count_follows_2_window_plus_5() {
// Max cells = first + last + one filler slot each side (number or ellipsis) + the numbers in
// the window itself (2 * window + 1) = 2 * window + 5. Must hold both for a centered page
// (baseline case) and near an edge (padded case), for any window size.
for window in [1_u64, 2, 3, 4, 5] {
let max_cells = 2 * window + 5;
let mut centered = Pager::new()
.with_base_path("/list")
.with_current_page(500)
.with_items_per_page(1)
.with_total_items(1000)
.with_window(window)
.with_prev_next(PagerVisibility::Never);
let html = centered.render(&mut Context::default()).await.into_string();
assert_eq!(
html.matches("page-item").count() as u64,
max_cells,
"window={window}, centered page"
);
let mut near_edge = Pager::new()
.with_base_path("/list")
.with_current_page(1)
.with_items_per_page(1)
.with_total_items(1000)
.with_window(window)
.with_prev_next(PagerVisibility::Never);
let html = near_edge
.render(&mut Context::default())
.await
.into_string();
assert_eq!(
html.matches("page-item").count() as u64,
max_cells,
"window={window}, page 1"
);
}
}
#[pagetop::test]
async fn window_pads_out_as_documented_in_the_module_example() {
// Matches the exact example in the doc comment of `Pager`.
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(1)
.with_items_per_page(1)
.with_total_items(200)
.with_window(3);
let html = pager.render(&mut Context::default()).await.into_string();
assert_eq!(html.matches("page-ellipsis").count(), 1);
for page in [1, 2, 3, 4, 5, 6, 7, 8, 9, 200] {
assert!(
html.contains(&format!(r#"href="/list?page={page}""#)),
"expected page {page} to be visible"
);
}
for page in [10, 11, 199] {
assert!(
!html.contains(&format!(r#"href="/list?page={page}""#)),
"expected page {page} to be hidden behind an ellipsis"
);
}
}
#[pagetop::test]
async fn window_pads_out_to_a_constant_cell_count_near_the_first_page() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(1)
.with_items_per_page(1)
.with_total_items(188)
.with_window(2);
let html = pager.render(&mut Context::default()).await.into_string();
// Near page 1 the low side of the window needs neither ellipsis nor filler number, so that gap
// is reinvested on the high side instead of just shrinking the window.
assert_eq!(html.matches("page-ellipsis").count(), 1);
for page in [1, 2, 3, 4, 5, 6, 7, 188] {
assert!(
html.contains(&format!(r#"href="/list?page={page}""#)),
"expected page {page} to be visible"
);
}
for page in [8, 9, 100, 187] {
assert!(
!html.contains(&format!(r#"href="/list?page={page}""#)),
"expected page {page} to be hidden behind an ellipsis"
);
}
}
#[pagetop::test]
async fn window_pads_out_to_a_constant_cell_count_near_the_last_page() {
let mut pager = Pager::new()
.with_base_path("/list")
.with_current_page(188)
.with_items_per_page(1)
.with_total_items(188)
.with_window(2);
let html = pager.render(&mut Context::default()).await.into_string();
assert_eq!(html.matches("page-ellipsis").count(), 1);
for page in [1, 182, 183, 184, 185, 186, 187, 188] {
assert!(
html.contains(&format!(r#"href="/list?page={page}""#)),
"expected page {page} to be visible"
);
}
for page in [2, 89, 181] {
assert!(
!html.contains(&format!(r#"href="/list?page={page}""#)),
"expected page {page} to be hidden behind an ellipsis"
);
}
}
#[pagetop::test] #[pagetop::test]
async fn small_total_is_never_truncated_even_with_a_window() { async fn small_total_is_never_truncated_even_with_a_window() {
let mut pager = Pager::new() let mut pager = Pager::new()