From 502c00c891cbe973bc0ae83679f8408d8b4729a7 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 5 Aug 2026 13:07:35 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8E=A8=20Colapsa=20if=20anidados=20su?= =?UTF-8?q?geridos=20por=20clippy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/theme/bs/navbar/item.rs | 8 ++++---- .../src/theme/token/layout.rs | 14 ++++++------- helpers/pagetop-statics/src/resource.rs | 8 ++++---- src/app.rs | 20 +++++++++---------- src/locale/definition.rs | 8 ++++---- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar/item.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar/item.rs index 27e785aa..721dae8c 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar/item.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/item.rs @@ -42,10 +42,10 @@ impl Component for Item { } fn setup(&mut self, _cx: &Context) { - if let Self::Nav(nav) = self { - if let Some(nav) = nav.get_mut() { - nav.alter_prop(PropsOp::prepend_classes("navbar-nav")); - } + if let Self::Nav(nav) = self + && let Some(nav) = nav.get_mut() + { + nav.alter_prop(PropsOp::prepend_classes("navbar-nav")); } } diff --git a/extensions/pagetop-bootsier/src/theme/token/layout.rs b/extensions/pagetop-bootsier/src/theme/token/layout.rs index a5df6b7c..69401e5d 100644 --- a/extensions/pagetop-bootsier/src/theme/token/layout.rs +++ b/extensions/pagetop-bootsier/src/theme/token/layout.rs @@ -52,14 +52,14 @@ impl ScaleSize { // Añade el tamaño a la cadena de clases usando el prefijo dado. #[inline] pub(crate) fn push_to(self, classes: &mut String, prefix: &str) { - if !prefix.is_empty() { - if let Some(suffix) = self.suffix() { - if !classes.is_empty() { - classes.push(' '); - } - classes.push_str(prefix); - classes.push_str(suffix); + if !prefix.is_empty() + && let Some(suffix) = self.suffix() + { + if !classes.is_empty() { + classes.push(' '); } + classes.push_str(prefix); + classes.push_str(suffix); } } } diff --git a/helpers/pagetop-statics/src/resource.rs b/helpers/pagetop-statics/src/resource.rs index 62a31ee7..f10c1871 100644 --- a/helpers/pagetop-statics/src/resource.rs +++ b/helpers/pagetop-statics/src/resource.rs @@ -158,10 +158,10 @@ fn collect_resources_nested>( let entry = entry?; let path = entry.path(); - if let Some(ref filter) = filter { - if !filter(path.as_ref()) { - continue; - } + if let Some(ref filter) = filter + && !filter(path.as_ref()) + { + continue; } if path.is_dir() { diff --git a/src/app.rs b/src/app.rs index 9f661ed6..7b4d197e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -84,16 +84,16 @@ impl Application { // Nombre de la aplicación, ajustado al ancho del terminal si es necesario. let mut app_ff = String::new(); let app_name = &global::SETTINGS.app.name; - if let Some((Width(term_width), _)) = terminal_size() { - if term_width >= 80 { - let maxlen: usize = ((term_width / 10) - 2).into(); - let mut app: String = app_name.chars().take(maxlen).collect(); - if app_name.chars().count() > maxlen { - app = format!("{app}..."); - } - if let Some(ff) = figfont::FIGFONT.convert(&app) { - app_ff = ff.to_string(); - } + if let Some((Width(term_width), _)) = terminal_size() + && term_width >= 80 + { + let maxlen: usize = ((term_width / 10) - 2).into(); + let mut app: String = app_name.chars().take(maxlen).collect(); + if app_name.chars().count() > maxlen { + app = format!("{app}..."); + } + if let Some(ff) = figfont::FIGFONT.convert(&app) { + app_ff = ff.to_string(); } } if app_ff.is_empty() { diff --git a/src/locale/definition.rs b/src/locale/definition.rs index 40767003..4f3191da 100644 --- a/src/locale/definition.rs +++ b/src/locale/definition.rs @@ -103,10 +103,10 @@ impl Locale { } // Si la variante regional no existe, retrocede al idioma base (p. ej. "es"). - if let Some((base_lang, _)) = lang.split_once('-') { - if let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid) { - return Self::Resolved(langid); - } + if let Some((base_lang, _)) = lang.split_once('-') + && let Some(langid) = LANGUAGES.get(base_lang).map(|(langid, _)| langid) + { + return Self::Resolved(langid); } // En caso contrario, indica que el idioma no está soportado. From e8bba0803d84214ad92c16fc3f744b2f81158cc9 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 5 Aug 2026 17:33:27 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20(form):=20A=C3=B1ade=20nuevo=20?= =?UTF-8?q?componente=20Number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/base/component/form.rs | 7 +- src/base/component/form/number.rs | 219 ++++++++++++++++++++++++++++++ src/base/component/form/range.rs | 24 ++-- 3 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 src/base/component/form/number.rs diff --git a/src/base/component/form.rs b/src/base/component/form.rs index 82c603ef..6e927367 100644 --- a/src/base/component/form.rs +++ b/src/base/component/form.rs @@ -20,11 +20,14 @@ pub mod select; pub mod input; -mod textarea; -pub use textarea::Textarea; +mod number; +pub use number::Number; mod range; pub use range::Range; +mod textarea; +pub use textarea::Textarea; + mod hidden; pub use hidden::Hidden; diff --git a/src/base/component/form/number.rs b/src/base/component/form/number.rs new file mode 100644 index 00000000..f8ad1c94 --- /dev/null +++ b/src/base/component/form/number.rs @@ -0,0 +1,219 @@ +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, + /// Devuelve la etiqueta del campo. + label: Attr, + /// Devuelve el texto de ayuda del campo. + help_text: Attr, + /// Devuelve el valor mínimo permitido. + min: Attr, + /// Devuelve el valor máximo permitido. + max: Attr, + /// Devuelve el incremento entre valores del campo. + step: Attr, + /// 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 { + 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 { + 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) -> 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) -> Self { + self.name.alter_name(name); + self + } + + /// Establece el valor inicial del campo. + #[builder_fn] + pub fn with_value(mut self, value: Option) -> 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>) -> 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>) -> 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) -> 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) -> 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) -> 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 + } +} diff --git a/src/base/component/form/range.rs b/src/base/component/form/range.rs index 51e8b385..97806665 100644 --- a/src/base/component/form/range.rs +++ b/src/base/component/form/range.rs @@ -35,6 +35,8 @@ pub struct Range { props: Props, /// Devuelve el nombre del campo. name: AttrName, + /// Devuelve el valor inicial del campo. + value: Attr, /// Devuelve la etiqueta del campo. label: Attr, /// Devuelve el texto de ayuda del campo. @@ -45,8 +47,6 @@ pub struct Range { max: Attr, /// Devuelve el incremento entre valores del campo. step: Attr, - /// Devuelve el valor inicial del campo. - value: Attr, /// Devuelve si el control recibe el foco automáticamente al cargar la página. autofocus: bool, /// Devuelve si el control está deshabilitado. @@ -129,6 +129,16 @@ impl Range { 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) -> 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>) -> Self { @@ -171,16 +181,6 @@ impl Range { 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) -> Self { - self.value.alter_opt(value); - self - } - /// Establece si el control recibe el foco automáticamente al cargar la página. #[builder_fn] pub fn with_autofocus(mut self, autofocus: bool) -> Self { From 3b01894024610481cd9bb925b5e27bb6e6c68d79 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 6 Aug 2026 22:37:28 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20(base):=20A=C3=B1ade=20componen?= =?UTF-8?q?te=20Pager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incluye soporte HTMX vía `sort_link()`/`hx_pager` para navegación sin recarga. --- examples/form-controls.rs | 18 +- extensions/pagetop-htmx/src/hx.rs | 7 + extensions/pagetop-htmx/src/hx_pager.rs | 32 ++ extensions/pagetop-htmx/src/hx_table.rs | 37 +- extensions/pagetop-htmx/src/lib.rs | 2 + extensions/pagetop-htmx/tests/hx_table.rs | 2 +- src/base/component.rs | 3 + src/base/component/form/hidden.rs | 10 + src/base/component/pager.rs | 422 ++++++++++++++++++++++ src/html/route_path.rs | 14 +- src/html/sort_dir.rs | 20 +- src/locale/en-US/base.ftl | 7 + src/locale/es-ES/base.ftl | 7 + tests/component_pager.rs | 331 +++++++++++++++++ 14 files changed, 881 insertions(+), 31 deletions(-) create mode 100644 extensions/pagetop-htmx/src/hx_pager.rs create mode 100644 src/base/component/pager.rs create mode 100644 tests/component_pager.rs diff --git a/examples/form-controls.rs b/examples/form-controls.rs index 29010de6..3e9a3f3d 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -137,11 +137,7 @@ async fn form_controls(request: HttpRequest) -> Result { ), ) // 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 { ), ) // 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( diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index fb4ae54b..2b5c08dc 100644 --- a/extensions/pagetop-htmx/src/hx.rs +++ b/extensions/pagetop-htmx/src/hx.rs @@ -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. diff --git a/extensions/pagetop-htmx/src/hx_pager.rs b/extensions/pagetop-htmx/src/hx_pager.rs new file mode 100644 index 00000000..7a126d17 --- /dev/null +++ b/extensions/pagetop-htmx/src/hx_pager.rs @@ -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 `