✨ (base): Añade componente Pager
Incluye soporte HTMX vía `sort_link()`/`hx_pager` para navegación sin recarga.
This commit is contained in:
parent
e8bba0803d
commit
3b01894024
14 changed files with 881 additions and 31 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
32
extensions/pagetop-htmx/src/hx_pager.rs
Normal file
32
extensions/pagetop-htmx/src/hx_pager.rs
Normal 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
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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""#));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
422
src/base/component/pager.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
331
tests/component_pager.rs
Normal 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}""#)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue