Compare commits
No commits in common. "3b01894024610481cd9bb925b5e27bb6e6c68d79" and "1cab0ae9a08cb600e6813702f959652147d072c3" have entirely different histories.
3b01894024
...
1cab0ae9a0
22 changed files with 74 additions and 1146 deletions
|
|
@ -137,7 +137,11 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Campo oculto (form::Hidden).
|
// Campo oculto (form::Hidden).
|
||||||
.with_child(form::Hidden::field("origin", "form-selections"))
|
.with_child(
|
||||||
|
form::Hidden::new()
|
||||||
|
.with_name("origin")
|
||||||
|
.with_value("form-selections"),
|
||||||
|
)
|
||||||
// Botones de acción.
|
// Botones de acción.
|
||||||
.with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop(
|
.with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop(
|
||||||
PropsOp::add_classes(class::ButtonColor::solid(
|
PropsOp::add_classes(class::ButtonColor::solid(
|
||||||
|
|
@ -257,7 +261,11 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Campo oculto (form::Hidden).
|
// Campo oculto (form::Hidden).
|
||||||
.with_child(form::Hidden::field("origin", "form-text"))
|
.with_child(
|
||||||
|
form::Hidden::new()
|
||||||
|
.with_name("origin")
|
||||||
|
.with_value("form-text"),
|
||||||
|
)
|
||||||
// Botones de acción.
|
// Botones de acción.
|
||||||
.with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop(
|
.with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop(
|
||||||
PropsOp::add_classes(class::ButtonColor::solid(
|
PropsOp::add_classes(class::ButtonColor::solid(
|
||||||
|
|
@ -407,7 +415,11 @@ fn form_lists() -> Form {
|
||||||
|
|
||||||
form
|
form
|
||||||
// Campo oculto (form::Hidden).
|
// Campo oculto (form::Hidden).
|
||||||
.with_child(form::Hidden::field("origin", "form-lists"))
|
.with_child(
|
||||||
|
form::Hidden::new()
|
||||||
|
.with_name("origin")
|
||||||
|
.with_value("form-lists"),
|
||||||
|
)
|
||||||
// Botones de acción.
|
// Botones de acción.
|
||||||
.with_child(
|
.with_child(
|
||||||
Button::submit(L10n::t("btn_submit", &LOC)).with_prop(PropsOp::add_classes(
|
Button::submit(L10n::t("btn_submit", &LOC)).with_prop(PropsOp::add_classes(
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,12 @@ impl Component for Item {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup(&mut self, _cx: &Context) {
|
fn setup(&mut self, _cx: &Context) {
|
||||||
if let Self::Nav(nav) = self
|
if let Self::Nav(nav) = self {
|
||||||
&& let Some(nav) = nav.get_mut()
|
if let Some(nav) = nav.get_mut() {
|
||||||
{
|
|
||||||
nav.alter_prop(PropsOp::prepend_classes("navbar-nav"));
|
nav.alter_prop(PropsOp::prepend_classes("navbar-nav"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
Ok(match self {
|
Ok(match self {
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,8 @@ impl ScaleSize {
|
||||||
// Añade el tamaño a la cadena de clases usando el prefijo dado.
|
// Añade el tamaño a la cadena de clases usando el prefijo dado.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) fn push_to(self, classes: &mut String, prefix: &str) {
|
pub(crate) fn push_to(self, classes: &mut String, prefix: &str) {
|
||||||
if !prefix.is_empty()
|
if !prefix.is_empty() {
|
||||||
&& let Some(suffix) = self.suffix()
|
if let Some(suffix) = self.suffix() {
|
||||||
{
|
|
||||||
if !classes.is_empty() {
|
if !classes.is_empty() {
|
||||||
classes.push(' ');
|
classes.push(' ');
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +61,7 @@ impl ScaleSize {
|
||||||
classes.push_str(suffix);
|
classes.push_str(suffix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **< BoxSide >************************************************************************************
|
// **< BoxSide >************************************************************************************
|
||||||
|
|
|
||||||
|
|
@ -490,13 +490,6 @@ pub mod swap {
|
||||||
pub const INNER_HTML: &str = "innerHTML";
|
pub const INNER_HTML: &str = "innerHTML";
|
||||||
/// Reemplaza el elemento objetivo completo.
|
/// Reemplaza el elemento objetivo completo.
|
||||||
pub const OUTER_HTML: &str = "outerHTML";
|
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.
|
/// Inserta la respuesta antes de la etiqueta de apertura del objetivo.
|
||||||
pub const BEFORE_BEGIN: &str = "beforebegin";
|
pub const BEFORE_BEGIN: &str = "beforebegin";
|
||||||
/// Inserta la respuesta al inicio del contenido del objetivo.
|
/// Inserta la respuesta al inicio del contenido del objetivo.
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
//! 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,17 +1,4 @@
|
||||||
//! Soporte HTMX al componente [`Table`].
|
//! 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::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
|
@ -19,13 +6,15 @@ use crate::hx;
|
||||||
|
|
||||||
// **< sort_link() >********************************************************************************
|
// **< sort_link() >********************************************************************************
|
||||||
|
|
||||||
/// Construye un [`SortLink`] para actualizar el orden de la tabla sin recargar la página.
|
/// Construye un [`SortLink`](pagetop::base::component::table::SortLink) para actualizar el orden de
|
||||||
|
/// la tabla sin recargar la página.
|
||||||
///
|
///
|
||||||
/// [`Table`] y `SortLink` no requieren HTMX. Cada extensión que quiera aplicar una navegación sin
|
/// [`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()`]. Como esos
|
/// recarga debe añadir sus propios atributos `hx-*` usando
|
||||||
|
/// [`SortLink::with_prop()`](pagetop::base::component::table::SortLink::with_prop). Como esos
|
||||||
/// cuatro atributos son siempre los mismos para cualquier cabecera ordenable (`hx-get` igual al
|
/// cuatro atributos son siempre los mismos para cualquier cabecera ordenable (`hx-get` igual al
|
||||||
/// `href`, `hx-swap="outerHTML scroll:top"` y `hx-push-url="true"`, y sólo `hx-target` cambia según
|
/// `href`, `hx-swap="outerHTML"` y `hx-push-url="true"`, y sólo `hx-target` cambia según la tabla),
|
||||||
/// la tabla), [`sort_link()`] evita reescribirlos en cada columna de cada listado.
|
/// [`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
|
/// 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.
|
/// estado de orden, así que navega correctamente aunque HTMX no esté disponible en el cliente.
|
||||||
|
|
@ -34,12 +23,13 @@ use crate::hx;
|
||||||
///
|
///
|
||||||
/// - `href`: URL completa hacia el nuevo estado de orden, reflejando ya el campo y la dirección
|
/// - `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`],
|
/// que resultarán de pulsar esta cabecera. Acepta cualquier tipo convertible a [`RoutePath`],
|
||||||
/// normalmente el resultado de [`Context::route()`], para que el enlace preserve el parámetro
|
/// normalmente el resultado de [`Context::route()`](pagetop::core::component::Context::route),
|
||||||
/// `lang` cuando corresponda.
|
/// 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
|
/// - `target`: selector CSS del elemento que HTMX debe reemplazar (`hx-target`), típicamente el
|
||||||
/// contenedor que envuelve la tabla completa.
|
/// contenedor que envuelve la tabla completa.
|
||||||
/// - `dir`: dirección de orden vigente de esta columna, o `None` si la tabla está ordenada
|
/// - `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()`].
|
/// actualmente por otra columna. Se traslada tal cual a
|
||||||
|
/// [`SortLink::with_dir()`](pagetop::base::component::table::SortLink::with_dir).
|
||||||
///
|
///
|
||||||
/// # Ejemplo
|
/// # Ejemplo
|
||||||
///
|
///
|
||||||
|
|
@ -66,11 +56,6 @@ use crate::hx;
|
||||||
/// .with_sort(hx_table::sort_link(href, "#user-table-wrapper", active))
|
/// .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(
|
pub fn sort_link(
|
||||||
href: impl Into<RoutePath>,
|
href: impl Into<RoutePath>,
|
||||||
target: impl AsRef<str>,
|
target: impl AsRef<str>,
|
||||||
|
|
@ -84,6 +69,6 @@ pub fn sort_link(
|
||||||
.with_dir(dir)
|
.with_dir(dir)
|
||||||
.with_prop(PropsOp::set(hx::GET, href))
|
.with_prop(PropsOp::set(hx::GET, href))
|
||||||
.with_prop(PropsOp::set(hx::TARGET, target))
|
.with_prop(PropsOp::set(hx::TARGET, target))
|
||||||
.with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML_SCROLL_TOP))
|
.with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML))
|
||||||
.with_prop(PropsOp::set(hx::PUSH_URL, "true"))
|
.with_prop(PropsOp::set(hx::PUSH_URL, "true"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,6 @@ use pagetop::prelude::*;
|
||||||
include_locales!(LOCALES_HTMX);
|
include_locales!(LOCALES_HTMX);
|
||||||
|
|
||||||
pub mod hx;
|
pub mod hx;
|
||||||
pub mod hx_pager;
|
|
||||||
pub mod hx_table;
|
pub mod hx_table;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
|
|
@ -100,7 +99,6 @@ pub mod response;
|
||||||
/// Prelude de `pagetop-htmx`.
|
/// Prelude de `pagetop-htmx`.
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use crate::hx;
|
pub use crate::hx;
|
||||||
pub use crate::hx_pager;
|
|
||||||
pub use crate::hx_table;
|
pub use crate::hx_table;
|
||||||
pub use crate::request::HtmxRequestExt;
|
pub use crate::request::HtmxRequestExt;
|
||||||
pub use crate::response::HtmxResponse;
|
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-get="/admin/users""#));
|
||||||
assert!(html.contains(r##"hx-target="#user-table""##));
|
assert!(html.contains(r##"hx-target="#user-table""##));
|
||||||
assert!(html.contains(r#"hx-swap="outerHTML scroll:top""#));
|
assert!(html.contains(r#"hx-swap="outerHTML""#));
|
||||||
assert!(html.contains(r#"hx-push-url="true""#));
|
assert!(html.contains(r#"hx-push-url="true""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,11 +158,11 @@ fn collect_resources_nested<P: AsRef<Path>>(
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
if let Some(ref filter) = filter
|
if let Some(ref filter) = filter {
|
||||||
&& !filter(path.as_ref())
|
if !filter(path.as_ref()) {
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
let nested = collect_resources(path, filter)?;
|
let nested = collect_resources(path, filter)?;
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,8 @@ impl Application {
|
||||||
// Nombre de la aplicación, ajustado al ancho del terminal si es necesario.
|
// Nombre de la aplicación, ajustado al ancho del terminal si es necesario.
|
||||||
let mut app_ff = String::new();
|
let mut app_ff = String::new();
|
||||||
let app_name = &global::SETTINGS.app.name;
|
let app_name = &global::SETTINGS.app.name;
|
||||||
if let Some((Width(term_width), _)) = terminal_size()
|
if let Some((Width(term_width), _)) = terminal_size() {
|
||||||
&& term_width >= 80
|
if term_width >= 80 {
|
||||||
{
|
|
||||||
let maxlen: usize = ((term_width / 10) - 2).into();
|
let maxlen: usize = ((term_width / 10) - 2).into();
|
||||||
let mut app: String = app_name.chars().take(maxlen).collect();
|
let mut app: String = app_name.chars().take(maxlen).collect();
|
||||||
if app_name.chars().count() > maxlen {
|
if app_name.chars().count() > maxlen {
|
||||||
|
|
@ -96,6 +95,7 @@ impl Application {
|
||||||
app_ff = ff.to_string();
|
app_ff = ff.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if app_ff.is_empty() {
|
if app_ff.is_empty() {
|
||||||
println!("\n{app_name}");
|
println!("\n{app_name}");
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,5 @@ pub use html::Html;
|
||||||
mod intro;
|
mod intro;
|
||||||
pub use intro::{Intro, IntroOpening};
|
pub use intro::{Intro, IntroOpening};
|
||||||
|
|
||||||
mod pager;
|
|
||||||
pub use pager::{Pager, PagerVisibility};
|
|
||||||
|
|
||||||
mod poweredby;
|
mod poweredby;
|
||||||
pub use poweredby::PoweredBy;
|
pub use poweredby::PoweredBy;
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,11 @@ pub mod select;
|
||||||
|
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
|
||||||
mod number;
|
mod textarea;
|
||||||
pub use number::Number;
|
pub use textarea::Textarea;
|
||||||
|
|
||||||
mod range;
|
mod range;
|
||||||
pub use range::Range;
|
pub use range::Range;
|
||||||
|
|
||||||
mod textarea;
|
|
||||||
pub use textarea::Textarea;
|
|
||||||
|
|
||||||
mod hidden;
|
mod hidden;
|
||||||
pub use hidden::Hidden;
|
pub use hidden::Hidden;
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,6 @@ use crate::prelude::*;
|
||||||
/// let token = form::Hidden::new()
|
/// let token = form::Hidden::new()
|
||||||
/// .with_name("csrf_token")
|
/// .with_name("csrf_token")
|
||||||
/// .with_value("a1b2c3d4e5");
|
/// .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
|
/// Al enviar el formulario el navegador transmite `name=valor`. En el servidor se deserializa
|
||||||
|
|
@ -55,13 +52,6 @@ impl Component for Hidden {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 >*************************************************************************
|
// **< Hidden BUILDER >*************************************************************************
|
||||||
|
|
||||||
/// Establece el nombre del campo oculto (atributo `name`).
|
/// Establece el nombre del campo oculto (atributo `name`).
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
use crate::prelude::*;
|
|
||||||
|
|
||||||
/// Componente para crear un **campo numérico**.
|
|
||||||
///
|
|
||||||
/// Renderiza un campo de entrada numérica (`input type="number"`) con una etiqueta opcional y un
|
|
||||||
/// texto de ayuda. El navegador restringe la entrada a valores numéricos y ofrece controles nativos
|
|
||||||
/// para incrementar o decrementar el valor.
|
|
||||||
///
|
|
||||||
/// # Ejemplo
|
|
||||||
///
|
|
||||||
/// ```rust,no_run
|
|
||||||
/// use pagetop::prelude::*;
|
|
||||||
///
|
|
||||||
/// let quantity = form::Number::new()
|
|
||||||
/// .with_name("quantity")
|
|
||||||
/// .with_label(L10n::n("Quantity"))
|
|
||||||
/// .with_min(Some(1))
|
|
||||||
/// .with_max(Some(100))
|
|
||||||
/// .with_value(Some(1));
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Al enviar el formulario el navegador transmite `name=valor`. Un campo numérico siempre envía su
|
|
||||||
/// valor. En el servidor se deserializa como `u64`:
|
|
||||||
///
|
|
||||||
/// ```rust,ignore
|
|
||||||
/// #[derive(serde::Deserialize)]
|
|
||||||
/// struct FormData {
|
|
||||||
/// quantity: u64, // Siempre presente con el valor numérico introducido.
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
|
||||||
pub struct Number {
|
|
||||||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
|
||||||
props: Props,
|
|
||||||
/// Devuelve el nombre del campo.
|
|
||||||
name: AttrName,
|
|
||||||
/// Devuelve el valor inicial del campo.
|
|
||||||
value: Attr<u64>,
|
|
||||||
/// Devuelve la etiqueta del campo.
|
|
||||||
label: Attr<L10n>,
|
|
||||||
/// Devuelve el texto de ayuda del campo.
|
|
||||||
help_text: Attr<L10n>,
|
|
||||||
/// Devuelve el valor mínimo permitido.
|
|
||||||
min: Attr<u64>,
|
|
||||||
/// Devuelve el valor máximo permitido.
|
|
||||||
max: Attr<u64>,
|
|
||||||
/// Devuelve el incremento entre valores del campo.
|
|
||||||
step: Attr<u64>,
|
|
||||||
/// Devuelve si el campo recibe el foco automáticamente al cargar la página.
|
|
||||||
autofocus: bool,
|
|
||||||
/// Devuelve si el campo es de sólo lectura.
|
|
||||||
readonly: bool,
|
|
||||||
/// Devuelve si el campo es obligatorio.
|
|
||||||
required: bool,
|
|
||||||
/// Devuelve si el campo está deshabilitado.
|
|
||||||
disabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Component for Number {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn id(&self) -> Option<String> {
|
|
||||||
self.props.get_id()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup(&mut self, _cx: &Context) {
|
|
||||||
if let Some(container_id) = self
|
|
||||||
.id()
|
|
||||||
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)))
|
|
||||||
{
|
|
||||||
self.alter_prop(PropsOp::ensure_id(container_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clases CSS del contenedor del campo numérico.
|
|
||||||
self.alter_prop(PropsOp::prepend_classes("form-field form-field-number"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
|
||||||
let container_id = self.id();
|
|
||||||
let input_id = container_id.as_deref().map(|id| util::join!(id, "-input"));
|
|
||||||
Ok(html! {
|
|
||||||
div (self.props()) {
|
|
||||||
@if let Some(label) = self.label().lookup(cx) {
|
|
||||||
label for=[input_id.as_deref()] class="form-label" {
|
|
||||||
(label)
|
|
||||||
@if *self.required() {
|
|
||||||
span
|
|
||||||
class="form-required"
|
|
||||||
title=(L10n::l("field_required").using(cx))
|
|
||||||
{
|
|
||||||
"*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
input
|
|
||||||
type="number"
|
|
||||||
id=[input_id.as_deref()]
|
|
||||||
class="form-control"
|
|
||||||
name=[self.name().get()]
|
|
||||||
min=[self.min().get()]
|
|
||||||
max=[self.max().get()]
|
|
||||||
step=[self.step().get()]
|
|
||||||
value=[self.value().get()]
|
|
||||||
autofocus[*self.autofocus()]
|
|
||||||
readonly[*self.readonly()]
|
|
||||||
required[*self.required()]
|
|
||||||
disabled[*self.disabled()];
|
|
||||||
@if let Some(description) = self.help_text().lookup(cx) {
|
|
||||||
div class="form-text" { (description) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Number {
|
|
||||||
// **< Number 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, atributos HTML o valores extra del componente.
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_prop(mut self, op: PropsOp) -> Self {
|
|
||||||
self.props.alter_prop(op);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece el nombre del campo (atributo `name`).
|
|
||||||
///
|
|
||||||
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
|
|
||||||
/// deserializar el campo en el servidor es recomendable establecer un `name` explícito.
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
|
|
||||||
self.name.alter_name(name);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece el valor inicial del campo.
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_value(mut self, value: Option<u64>) -> Self {
|
|
||||||
self.value.alter_opt(value);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece o elimina la etiqueta visible del campo (basta pasar `None` para quitarla).
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
|
|
||||||
self.label.alter_opt(label.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece o elimina el texto de ayuda del campo (basta pasar `None` para quitarlo).
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> Self {
|
|
||||||
self.help_text.alter_opt(help_text.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece el valor mínimo permitido (`None` para no imponer mínimo).
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_min(mut self, min: Option<u64>) -> Self {
|
|
||||||
self.min.alter_opt(min);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece el valor máximo permitido (`None` para no imponer máximo).
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_max(mut self, max: Option<u64>) -> Self {
|
|
||||||
self.max.alter_opt(max);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece el incremento entre valores del campo.
|
|
||||||
///
|
|
||||||
/// Pasar `None` omite el atributo `step` y deja que el navegador aplique su valor por defecto
|
|
||||||
/// (normalmente `1`).
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_step(mut self, step: Option<u64>) -> Self {
|
|
||||||
self.step.alter_opt(step);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece si el campo recibe el foco automáticamente al cargar la página.
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
|
|
||||||
self.autofocus = autofocus;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece si el campo es de sólo lectura.
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_readonly(mut self, readonly: bool) -> Self {
|
|
||||||
self.readonly = readonly;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece si el campo es obligatorio.
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_required(mut self, required: bool) -> Self {
|
|
||||||
self.required = required;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece si el campo está deshabilitado.
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_disabled(mut self, disabled: bool) -> Self {
|
|
||||||
self.disabled = disabled;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -35,8 +35,6 @@ pub struct Range {
|
||||||
props: Props,
|
props: Props,
|
||||||
/// Devuelve el nombre del campo.
|
/// Devuelve el nombre del campo.
|
||||||
name: AttrName,
|
name: AttrName,
|
||||||
/// Devuelve el valor inicial del campo.
|
|
||||||
value: Attr<f64>,
|
|
||||||
/// Devuelve la etiqueta del campo.
|
/// Devuelve la etiqueta del campo.
|
||||||
label: Attr<L10n>,
|
label: Attr<L10n>,
|
||||||
/// Devuelve el texto de ayuda del campo.
|
/// Devuelve el texto de ayuda del campo.
|
||||||
|
|
@ -47,6 +45,8 @@ pub struct Range {
|
||||||
max: Attr<f64>,
|
max: Attr<f64>,
|
||||||
/// Devuelve el incremento entre valores del campo.
|
/// Devuelve el incremento entre valores del campo.
|
||||||
step: Attr<f64>,
|
step: Attr<f64>,
|
||||||
|
/// Devuelve el valor inicial del campo.
|
||||||
|
value: Attr<f64>,
|
||||||
/// Devuelve si el control recibe el foco automáticamente al cargar la página.
|
/// Devuelve si el control recibe el foco automáticamente al cargar la página.
|
||||||
autofocus: bool,
|
autofocus: bool,
|
||||||
/// Devuelve si el control está deshabilitado.
|
/// Devuelve si el control está deshabilitado.
|
||||||
|
|
@ -129,16 +129,6 @@ impl Range {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Establece el valor inicial del campo.
|
|
||||||
///
|
|
||||||
/// Pasar `None` omite el atributo `value` y deja que el navegador aplique su valor por defecto
|
|
||||||
/// (normalmente el punto medio del rango).
|
|
||||||
#[builder_fn]
|
|
||||||
pub fn with_value(mut self, value: Option<f64>) -> Self {
|
|
||||||
self.value.alter_opt(value);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establece o elimina la etiqueta visible del campo (basta pasar `None` para quitarla).
|
/// Establece o elimina la etiqueta visible del campo (basta pasar `None` para quitarla).
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
|
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
|
||||||
|
|
@ -181,6 +171,16 @@ impl Range {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Establece el valor inicial del campo.
|
||||||
|
///
|
||||||
|
/// Pasar `None` omite el atributo `value` y deja que el navegador aplique su valor por defecto
|
||||||
|
/// (normalmente el punto medio del rango).
|
||||||
|
#[builder_fn]
|
||||||
|
pub fn with_value(mut self, value: Option<f64>) -> Self {
|
||||||
|
self.value.alter_opt(value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Establece si el control recibe el foco automáticamente al cargar la página.
|
/// Establece si el control 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,422 +0,0 @@
|
||||||
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
|
/// Un `value` vacío no se distingue de [`with_flag()`](Self::with_flag): ambos se renderizan
|
||||||
/// como `?key`, sin `=`.
|
/// como `?key`, sin `=`.
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
pub fn with_param(mut self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
|
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
self.query
|
self.query
|
||||||
.insert(key.into(), Self::encode_query_value(value.as_ref()));
|
.insert(key.into(), Self::encode_query_value(&value.into()));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,16 +91,6 @@ impl RoutePath {
|
||||||
&self.path
|
&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
|
/// Indica si el *path* **parece** una URL externa por su prefijo (ver
|
||||||
/// [`util::url_looks_external()`](crate::util::url_looks_external)).
|
/// [`util::url_looks_external()`](crate::util::url_looks_external)).
|
||||||
pub fn is_external(&self) -> bool {
|
pub fn is_external(&self) -> bool {
|
||||||
|
|
|
||||||
|
|
@ -99,25 +99,9 @@ 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
|
/// Permite pasar un [`SortDir`] allí donde se espere `impl Into<String>`, por ejemplo, en
|
||||||
/// [`Pager::with_extra_query()`](crate::base::component::Pager::with_extra_query), sin tener que
|
/// [`RoutePath::with_param()`](crate::html::RoutePath::with_param) o su equivalente
|
||||||
/// escribir `as_str().to_owned()` a mano.
|
/// `alter_param()`, sin tener que escribir `as_str().to_owned()` a mano.
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// use pagetop::html::SortDir;
|
/// use pagetop::html::SortDir;
|
||||||
|
|
|
||||||
|
|
@ -103,11 +103,11 @@ impl Locale {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si la variante regional no existe, retrocede al idioma base (p. ej. "es").
|
// Si la variante regional no existe, retrocede al idioma base (p. ej. "es").
|
||||||
if let Some((base_lang, _)) = lang.split_once('-')
|
if let Some((base_lang, _)) = lang.split_once('-') {
|
||||||
&& let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid)
|
if let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid) {
|
||||||
{
|
|
||||||
return Self::Resolved(langid);
|
return Self::Resolved(langid);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// En caso contrario, indica que el idioma no está soportado.
|
// En caso contrario, indica que el idioma no está soportado.
|
||||||
Self::Unsupported(language.to_string())
|
Self::Unsupported(language.to_string())
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,3 @@ intro_have_fun = Coding is creating
|
||||||
|
|
||||||
# PoweredBy component.
|
# PoweredBy component.
|
||||||
poweredby_pagetop = Powered by { $pagetop_link }
|
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,10 +18,3 @@ intro_have_fun = Programar es crear
|
||||||
|
|
||||||
# PoweredBy component.
|
# PoweredBy component.
|
||||||
poweredby_pagetop = Funciona con { $pagetop_link }
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,331 +0,0 @@
|
||||||
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