From e24b9ab9debb65a63a4289b1da83e1fe80a7329d Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 22 Aug 2026 09:44:28 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(pagetop):=20Logotipo?= =?UTF-8?q?=20embebido=20y=20Badge=20en=20Intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PageTopSvg::markup_with()` fusiona `Props` y controla la etiqueta accesible directamente en el ``. - `Image` ya no duplica accesibilidad para el logotipo y corrige el `alt` vacío en el resto de fuentes (Responsive/Thumbnail/Plain). - Sustituye `BadgeKind` y el `ColorName`/`IntoColor` de `core/theme` por un `Intent` compartido. --- assets/css/basic.css | 37 ++++++++++ extensions/pagetop-bootsier/src/theme/bs.rs | 2 +- .../pagetop-bootsier/src/theme/bs/badge.rs | 18 ++--- src/base/component.rs | 2 +- src/base/component/badge.rs | 56 +++++---------- src/base/component/brand.rs | 41 ++++++----- src/base/component/image/component.rs | 38 +++++++---- src/base/component/intro.rs | 4 +- src/core/theme.rs | 4 +- src/core/theme/color.rs | 68 ------------------- src/core/theme/intent.rs | 31 +++++++++ src/html/logo.rs | 63 ++++++++++++----- src/locale/en-US/theme.ftl | 4 +- src/locale/es-ES/theme.ftl | 4 +- tests/component_badge.rs | 14 ++-- 15 files changed, 199 insertions(+), 187 deletions(-) delete mode 100644 src/core/theme/color.rs create mode 100644 src/core/theme/intent.rs diff --git a/assets/css/basic.css b/assets/css/basic.css index 3e199708..818db21a 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -44,6 +44,23 @@ body { -webkit-tap-highlight-color: transparent; } +/* + * Brand component + */ + +.brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--val-color--text); + font-size: 1.75rem; + font-weight: 600; + text-decoration: none; +} +.brand .image { + display: flex; +} + /* * Badge component */ @@ -280,6 +297,26 @@ input:disabled + label { border: 0; } +/* + * Image component + */ + +.image { + vertical-align: middle; +} +.image-fluid { + max-width: 100%; + height: auto; +} +.image-thumbnail { + max-width: 100%; + height: auto; + padding: 0.25rem; + background-color: var(--val-color--bg); + border: 1px solid var(--val-color--border); + border-radius: 0.375rem; +} + /* * Messages component */ diff --git a/extensions/pagetop-bootsier/src/theme/bs.rs b/extensions/pagetop-bootsier/src/theme/bs.rs index ae101e55..8fabce2b 100644 --- a/extensions/pagetop-bootsier/src/theme/bs.rs +++ b/extensions/pagetop-bootsier/src/theme/bs.rs @@ -2,7 +2,7 @@ // Badge. pub(crate) mod badge; -pub use badge::{Badge, BadgeKind}; +pub use badge::Badge; // Button. mod button; diff --git a/extensions/pagetop-bootsier/src/theme/bs/badge.rs b/extensions/pagetop-bootsier/src/theme/bs/badge.rs index 615119c9..43dceae8 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/badge.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/badge.rs @@ -1,18 +1,14 @@ use pagetop::prelude::*; -pub use pagetop::base::component::{Badge, BadgeKind}; +pub use pagetop::base::component::Badge; // **< Badge SETUP >******************************************************************************** -#[rustfmt::skip] pub(crate) fn setup(badge: &mut Badge) { - let (old, new) = match badge.kind() { - BadgeKind::Primary => ("badge-primary", "text-bg-primary"), - BadgeKind::Secondary => ("badge-secondary", "text-bg-secondary"), - BadgeKind::Success => ("badge-success", "text-bg-success"), - BadgeKind::Info => ("badge-info", "text-bg-info"), - BadgeKind::Warning => ("badge-warning", "text-bg-warning"), - BadgeKind::Danger => ("badge-danger", "text-bg-danger"), - }; - badge.alter_prop(PropsOp::replace_classes(old, new)); + let intent = badge.intent().as_str(); + + badge.alter_prop(PropsOp::replace_classes( + util::join!("badge-", intent), + util::join!("text-bg-", intent), + )); } diff --git a/src/base/component.rs b/src/base/component.rs index 53ccafdf..e01ed9af 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -3,7 +3,7 @@ pub mod layout; mod badge; -pub use badge::{Badge, BadgeKind}; +pub use badge::Badge; mod brand; pub use brand::Brand; diff --git a/src/base/component/badge.rs b/src/base/component/badge.rs index 12fa7ca8..a01a61c5 100644 --- a/src/base/component/badge.rs +++ b/src/base/component/badge.rs @@ -1,28 +1,12 @@ use crate::prelude::*; -// **< BadgeKind >********************************************************************************** - -/// Tipo de [`Badge`]. -#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] -pub enum BadgeKind { - Primary, - #[default] - Secondary, - Success, - Info, - Warning, - Danger, -} - -// **< Badge >************************************************************************************** - /// Componente para mostrar una **etiqueta corta informativa** (*badge*). /// /// # Ejemplo /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// let badge = Badge::labeled(Lc::n("Admin")).with_kind(BadgeKind::Danger); +/// let badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger); /// /// // Equivalente usando el constructor directo del tipo. /// let badge = Badge::danger(Lc::n("Admin")); @@ -33,9 +17,10 @@ pub struct Badge { props: Props, /// Devuelve la etiqueta del badge. label: Lc, - /// Devuelve el tipo del badge. + /// Devuelve la intención semántica del badge. #[getters(copy)] - kind: BadgeKind, + #[default(Intent::Secondary)] + intent: Intent, } #[async_trait] @@ -48,16 +33,11 @@ impl Component for Badge { self.props.get_id() } - #[rustfmt::skip] fn setup(&mut self, _cx: &Context) { - self.alter_prop(PropsOp::prepend_classes(match self.kind() { - BadgeKind::Primary => "badge badge-primary", - BadgeKind::Secondary => "badge badge-secondary", - BadgeKind::Success => "badge badge-success", - BadgeKind::Info => "badge badge-info", - BadgeKind::Warning => "badge badge-warning", - BadgeKind::Danger => "badge badge-danger", - })); + self.alter_prop(PropsOp::prepend_classes(util::join!( + "badge badge-", + self.intent().as_str() + ))); } async fn prepare(&self, cx: &mut Context) -> Result { @@ -70,7 +50,7 @@ impl Component for Badge { } impl Badge { - /// Crea un badge predeterminado (`BadgeKind::default()`) con la etiqueta indicada. + /// Crea un badge predeterminado (`Intent::default()`) con la etiqueta indicada. pub fn labeled(label: Lc) -> Self { Self { label, @@ -82,7 +62,7 @@ impl Badge { pub fn primary(label: Lc) -> Self { Self { label, - kind: BadgeKind::Primary, + intent: Intent::Primary, ..Default::default() } } @@ -91,7 +71,7 @@ impl Badge { pub fn secondary(label: Lc) -> Self { Self { label, - kind: BadgeKind::Secondary, + intent: Intent::Secondary, ..Default::default() } } @@ -100,7 +80,7 @@ impl Badge { pub fn success(label: Lc) -> Self { Self { label, - kind: BadgeKind::Success, + intent: Intent::Success, ..Default::default() } } @@ -109,7 +89,7 @@ impl Badge { pub fn info(label: Lc) -> Self { Self { label, - kind: BadgeKind::Info, + intent: Intent::Info, ..Default::default() } } @@ -118,7 +98,7 @@ impl Badge { pub fn warning(label: Lc) -> Self { Self { label, - kind: BadgeKind::Warning, + intent: Intent::Warning, ..Default::default() } } @@ -127,7 +107,7 @@ impl Badge { pub fn danger(label: Lc) -> Self { Self { label, - kind: BadgeKind::Danger, + intent: Intent::Danger, ..Default::default() } } @@ -155,10 +135,10 @@ impl Badge { self } - /// Establece el tipo del badge. + /// Establece la intención semántica del badge. #[builder_fn] - pub fn with_kind(mut self, kind: BadgeKind) -> Self { - self.kind = kind; + pub fn with_intent(mut self, intent: Intent) -> Self { + self.intent = intent; self } } diff --git a/src/base/component/brand.rs b/src/base/component/brand.rs index fa7398b1..a1113ef3 100644 --- a/src/base/component/brand.rs +++ b/src/base/component/brand.rs @@ -2,14 +2,14 @@ use crate::prelude::*; /// Componente para mostrar la **identidad de marca** de un sitio o aplicación. /// -/// Combina una imagen, un título y un eslogan opcional, típicamente en la cabecera de la página o -/// dentro de una barra de navegación proporcionada por un tema. +/// Combina una imagen y un título, típicamente en la cabecera de la página o dentro de una barra de +/// navegación. /// /// - Si hay ruta ([`with_route()`]), el bloque completo actúa como enlace. Por defecto enlaza a la /// raíz del sitio (`/`). -/// - Si no hay imagen ([`with_image()`]) ni título ([`with_title()`]), la marca de identidad no se -/// renderiza. -/// - El eslogan ([`with_slogan()`]) es opcional; por defecto no tiene contenido. +/// - Si no tiene ningún contenido (ni imagen ni título), la marca de identidad no se renderiza. +/// - El título predefinido es el nombre de la aplicación ([`global::SETTINGS.app.name`]). La imagen +/// es opcional, por defecto no tiene contenido. /// /// # Ejemplo /// @@ -17,15 +17,13 @@ use crate::prelude::*; /// use pagetop::prelude::*; /// /// let brand = Brand::new() -/// .with_image(Some(Image::with(image::Source::logo(PageTopSvg::Color)))) +/// .with_image(image::Source::logo(PageTopSvg::Color)) /// .with_title(Lc::n("PageTop")) /// .with_route(Route::from("/")); /// ``` /// /// [`with_route()`]: Self::with_route -/// [`with_image()`]: Self::with_image -/// [`with_title()`]: Self::with_title -/// [`with_slogan()`]: Self::with_slogan +/// [`global::SETTINGS.app.name`]: crate::global::App::name #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Brand { /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. @@ -35,8 +33,6 @@ pub struct Brand { /// Devuelve el título de la identidad de marca. #[default(_code = "Lc::n(&global::SETTINGS.app.name)")] title: Lc, - /// Devuelve el eslogan de la marca. - slogan: Lc, /// Devuelve la ruta asociada a la marca (si existe). #[default(_code = "Some(\"/\".into())")] route: Option, @@ -59,15 +55,23 @@ impl Component for Brand { async fn prepare(&self, cx: &mut Context) -> Result { let image = self.image().render(cx).await; let title = self.title().using(cx); - if image.is_empty() && title.is_empty() { + let inner_brand = html! { + (image) + @if !title.is_empty() { + @if !image.is_empty() { + (" ") + } + span class="brand-title" { (title) } + } + }; + if inner_brand.is_empty() { return Ok(html! {}); } - let slogan = self.slogan().using(cx); Ok(html! { @if let Some(route) = self.route() { - a (self.props()) href=(route.resolve(cx)) { (image) (title) (slogan) } + a (self.props()) href=(route.resolve(cx)) { (inner_brand) } } @else { - span (self.props()) { (image) (title) (slogan) } + span (self.props()) { (inner_brand) } } }) } @@ -104,13 +108,6 @@ impl Brand { self } - /// Define el eslogan de la marca. - #[builder_fn] - pub fn with_slogan(mut self, slogan: Lc) -> Self { - self.slogan = slogan; - self - } - /// Define la ruta de destino. Si es `None`, la marca no será un enlace. #[builder_fn] pub fn with_route(mut self, route: impl Into>) -> Self { diff --git a/src/base/component/image/component.rs b/src/base/component/image/component.rs index 220a079e..344df838 100644 --- a/src/base/component/image/component.rs +++ b/src/base/component/image/component.rs @@ -49,6 +49,13 @@ impl Component for Image { image::Source::Thumbnail(_) => "image image-thumbnail", image::Source::Plain(_) => "image", })); + + // Se asigna un tamaño predefinido para el logotipo que `with_size()` puede sobrescribir. + if matches!(self.source(), image::Source::Logo(_)) { + self.alter_prop(PropsOp::add_style("width", "1.25em")); + self.alter_prop(PropsOp::add_style("height", "1.25em")); + } + // El tamaño se aplica como declaraciones `style` individuales sobre `Props`. match *self.size() { image::Size::Auto => {} @@ -72,18 +79,9 @@ impl Component for Image { async fn prepare(&self, cx: &mut Context) -> Result { let alt_text = self.alternative().lookup(cx).unwrap_or_default(); let source = match self.source() { - image::Source::Logo(logo) => { - let is_decorative = alt_text.is_empty(); - return Ok(html! { - span - (self.props()) - role=[(!is_decorative).then_some("img")] - aria-label=[(!is_decorative).then_some(alt_text)] - aria-hidden=[is_decorative.then_some("true")] - { - (logo.markup(cx)) - } - }); + image::Source::Logo(svg) => { + let label = (!alt_text.is_empty()).then_some(alt_text.as_str()); + return Ok(svg.markup_with(self.props(), label)); } image::Source::Responsive(source) => Some(source), image::Source::Thumbnail(source) => Some(source), @@ -144,3 +142,19 @@ impl Image { self } } + +impl From for Image { + /// Igual que [`Image::with()`]. + fn from(source: image::Source) -> Self { + Self::with(source) + } +} + +impl From for Option { + /// Permite pasar un [`image::Source`] directamente donde se espera `impl Into>` + /// (p. ej. [`Brand::with_image()`](super::super::Brand::with_image)), sin construir la + /// [`Image`] a mano. + fn from(source: image::Source) -> Self { + Some(Image::with(source)) + } +} diff --git a/src/base/component/intro.rs b/src/base/component/intro.rs index f2c306c1..e339fd27 100644 --- a/src/base/component/intro.rs +++ b/src/base/component/intro.rs @@ -148,7 +148,7 @@ impl Component for Intro { } aside class="intro-header-img" aria-hidden="true" { div class="intro-header-mascot" { - (PageTopSvg::Color.markup(cx)) + (PageTopSvg::Color.markup()) } } } @@ -201,7 +201,7 @@ impl Component for Intro { div class="intro-footer" { section class="intro-footer-body" { div class="intro-footer-logo" { - (PageTopSvg::LineLight.markup(cx)) + (PageTopSvg::LineLight.markup()) } div class="intro-footer-links" { a href="https://crates.io/crates/pagetop" target="_blank" rel="noopener noreferrer" { ("Crates.io") } diff --git a/src/core/theme.rs b/src/core/theme.rs index bad50216..09dd0a2e 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -117,8 +117,8 @@ //! //! [`ReservedRegions`]: crate::response::ReservedRegions -mod color; -pub use color::{ColorName, CoreColors, IntoColor}; +mod intent; +pub use intent::Intent; mod layout; pub use layout::{CoreRegions, RegionName, RegionRef}; diff --git a/src/core/theme/color.rs b/src/core/theme/color.rs deleted file mode 100644 index 62cf5cd0..00000000 --- a/src/core/theme/color.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::AutoDefault; - -// **< ColorName >********************************************************************************** - -/// Interfaz común para los colores de la paleta de un tema. -/// -/// PageTop ofrece una implementación predeterminada en [`CoreColors`], aunque probablemente cada -/// tema proporcionará su propia lista de colores implementando este trait. -pub trait ColorName { - /// Devuelve el nombre asociado al color (p. ej. `"primary"`, `"danger"`, etc.). Normalmente se - /// usará para generar la clase CSS del componente. - fn name(self) -> &'static str; -} - -// **< IntoColor >********************************************************************************** - -/// Convierte un color, o su ausencia, en el nombre ya resuelto. -/// -/// Permite que un método como `with_color(color: impl IntoColor)` acepte indistintamente un color -/// directo (`CoreColors::Danger`) o uno opcional (`Some(CoreColors::Danger)`, o `None` para no -/// aplicar ninguno). -/// -/// Evita la ambigüedad de `impl From for T` junto con `impl From for Option` al -/// resolver un `C` genérico acotado por [`ColorName`]. Al ser [`IntoColor`] un trait propio, -/// ninguna implementación de [`ColorName`] cubre nunca `Option` y la resolución no es ambigua. -pub trait IntoColor { - /// Devuelve el nombre del color ya resuelto, o `None` si no se aplica ninguno. - fn into_color(self) -> Option<&'static str>; -} - -impl IntoColor for C { - fn into_color(self) -> Option<&'static str> { - Some(self.name()) - } -} - -impl IntoColor for Option { - fn into_color(self) -> Option<&'static str> { - self.map(ColorName::name) - } -} - -// **< CoreColors >********************************************************************************* - -/// Paleta de colores predeterminada de PageTop. -#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] -pub enum CoreColors { - #[default] - Primary, - Secondary, - Success, - Info, - Warning, - Danger, -} - -impl ColorName for CoreColors { - fn name(self) -> &'static str { - match self { - Self::Primary => "primary", - Self::Secondary => "secondary", - Self::Success => "success", - Self::Info => "info", - Self::Warning => "warning", - Self::Danger => "danger", - } - } -} diff --git a/src/core/theme/intent.rs b/src/core/theme/intent.rs new file mode 100644 index 00000000..ff6e4626 --- /dev/null +++ b/src/core/theme/intent.rs @@ -0,0 +1,31 @@ +use crate::AutoDefault; + +/// Intención semántica de un componente visual. +/// +/// Representa la intención que pretende comunicar un componente (énfasis principal, confirmación de +/// un evento exitoso, aviso, peligro, etc.). Cada tema decidirá cómo pintarlo. +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum Intent { + #[default] + Primary, + Secondary, + Success, + Info, + Warning, + Danger, +} + +impl Intent { + /// Devuelve el nombre de la intención (`"primary"`, `"danger"`, etc.). + #[rustfmt::skip] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Primary => "primary", + Self::Secondary => "secondary", + Self::Success => "success", + Self::Info => "info", + Self::Warning => "warning", + Self::Danger => "danger", + } + } +} diff --git a/src/html/logo.rs b/src/html/logo.rs index 0aafca55..2953849c 100644 --- a/src/html/logo.rs +++ b/src/html/logo.rs @@ -1,7 +1,5 @@ use crate::AutoDefault; -use crate::core::component::Context; -use crate::html::{Markup, html}; -use crate::locale::Lc; +use crate::html::{Markup, Props, html}; /// Representación SVG del **logotipo de PageTop** para incrustar en HTML. /// @@ -9,19 +7,19 @@ use crate::locale::Lc; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// fn render_logo(cx: &mut Context) -> Markup { +/// fn render_logo() -> Markup { /// html! { /// div class="logo_color" { -/// (PageTopSvg::Color.markup(cx)) +/// (PageTopSvg::Color.markup()) /// } /// div class="line_dark" { -/// (PageTopSvg::LineDark.markup(cx)) +/// (PageTopSvg::LineDark.markup()) /// } /// div class="line_light" { -/// (PageTopSvg::LineLight.markup(cx)) +/// (PageTopSvg::LineLight.markup()) /// } /// div class="line_red" { -/// (PageTopSvg::LineRGB(255, 0, 0).markup(cx)) +/// (PageTopSvg::LineRGB(255, 0, 0).markup()) /// } /// } /// }; @@ -42,29 +40,60 @@ pub enum PageTopSvg { impl PageTopSvg { /// Devuelve el marcado SVG del logotipo según la variante elegida. - pub fn markup(&self, cx: &Context) -> Markup { - let path_fills = match self { - Self::Color => self.logo_color(), - Self::LineDark => self.logo_line(10, 11, 9), - Self::LineLight => self.logo_line(255, 255, 255), - Self::LineRGB(r, g, b) => self.logo_line(*r, *g, *b), - }; + pub fn markup(&self) -> Markup { html! { svg viewBox="0 0 1614 1614" xmlns="http://www.w3.org/2000/svg" role="img" - aria-label=[Lc::l("pagetop_logo").lookup(cx)] + aria-label="PageTop" preserveAspectRatio="xMidYMid slice" focusable="false" { - (path_fills) + (self.path_fills()) + } + } + } + + /// Igual que [`markup()`], pero fusiona [`Props`] (identificador, clases, estilo, atributos) + /// directamente en el `` y deja la etiqueta libre para quien llama. Con `Some(texto)` se + /// muestra como imagen informativa (`role="img"` + `aria-label`), y con `None` como imagen + /// puramente decorativa (`aria-hidden="true"`, sin `role` ni `aria-label`). + /// + /// Pensado para poner el logotipo con clases, estilo y accesibilidad propios, sin envolverlo en + /// un elemento aparte; como hace el componente [`Image`] con [`image::Source::Logo`]. + /// + /// [`markup()`]: Self::markup + /// [`Image`]: crate::base::component::Image + /// [`image::Source::Logo`]: crate::base::component::image::Source::Logo + pub fn markup_with(&self, props: &Props, label: Option<&str>) -> Markup { + html! { + svg + viewBox="0 0 1614 1614" + xmlns="http://www.w3.org/2000/svg" + role=[label.is_some().then_some("img")] + aria-label=[label] + aria-hidden=[label.is_none().then_some("true")] + preserveAspectRatio="xMidYMid slice" + focusable="false" + (props) + { + (self.path_fills()) } } } // **< PageTopSvg HELPERS >********************************************************************* + fn path_fills(&self) -> Markup { + match self { + Self::Color => self.logo_color(), + Self::LineDark => self.logo_line(10, 11, 9), + Self::LineLight => self.logo_line(255, 255, 255), + Self::LineRGB(r, g, b) => self.logo_line(*r, *g, *b), + } + } + fn logo_color(&self) -> Markup { html! { path fill="rgb(255,184,75)" d="M 633,61 L 633,61 C 579,61 527,75 480,102 433,129 395,167 368,214 341,261 327,313 327,367 L 327,1244 327,1245 C 327,1299 341,1351 368,1398 395,1445 433,1483 480,1510 527,1537 579,1551 633,1551 L 982,1550 982,1551 C 1036,1551 1088,1537 1135,1510 1182,1483 1220,1445 1247,1398 1274,1351 1288,1299 1288,1245 L 1288,367 1288,367 1288,367 C 1288,313 1274,261 1247,214 1220,167 1182,129 1135,102 1088,75 1036,61 982,61 L 633,61 Z" {} diff --git a/src/locale/en-US/theme.ftl b/src/locale/en-US/theme.ftl index 3f4c0064..885eb841 100644 --- a/src/locale/en-US/theme.ftl +++ b/src/locale/en-US/theme.ftl @@ -2,9 +2,7 @@ region_header = Header region_content = Content region_footer = Footer - -# Logo. -pagetop_logo = PageTop Logo +region_aside = Aside # Error Messages. error_code = Error { $code } diff --git a/src/locale/es-ES/theme.ftl b/src/locale/es-ES/theme.ftl index 7d4abcf6..d77ee3c9 100644 --- a/src/locale/es-ES/theme.ftl +++ b/src/locale/es-ES/theme.ftl @@ -2,9 +2,7 @@ region_header = Cabecera region_content = Contenido region_footer = Pie de página - -# Logo. -pagetop_logo = Logotipo de PageTop +region_aside = Barra lateral # Error Messages. error_code = Error { $code } diff --git a/tests/component_badge.rs b/tests/component_badge.rs index f148c023..2d039e3b 100644 --- a/tests/component_badge.rs +++ b/tests/component_badge.rs @@ -28,7 +28,7 @@ async fn renders_as_a_span_element() { } #[pagetop::test] -async fn default_kind_is_secondary() { +async fn default_intent_is_secondary() { let mut badge = Badge::labeled(Lc::n("Admin")); let html = badge.render(&mut Context::default()).await.into_string(); @@ -36,7 +36,7 @@ async fn default_kind_is_secondary() { } #[pagetop::test] -async fn each_direct_constructor_sets_its_kind() { +async fn each_direct_constructor_sets_its_intent() { let cases = [ (Badge::primary(Lc::n("x")), "badge-primary"), (Badge::secondary(Lc::n("x")), "badge-secondary"), @@ -55,17 +55,17 @@ async fn each_direct_constructor_sets_its_kind() { } #[pagetop::test] -async fn with_kind_overrides_the_default() { - let mut badge = Badge::labeled(Lc::n("Admin")).with_kind(BadgeKind::Danger); +async fn with_intent_overrides_the_default() { + let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger); let html = badge.render(&mut Context::default()).await.into_string(); assert!(html.contains(r#"class="badge badge-danger""#)); } #[pagetop::test] -async fn direct_constructor_is_equivalent_to_labeled_with_kind() { +async fn direct_constructor_is_equivalent_to_labeled_with_intent() { let mut from_constructor = Badge::danger(Lc::n("Admin")); - let mut from_builder = Badge::labeled(Lc::n("Admin")).with_kind(BadgeKind::Danger); + let mut from_builder = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger); assert_eq!( from_constructor @@ -88,7 +88,7 @@ async fn with_id_sets_the_identifier() { } #[pagetop::test] -async fn with_prop_adds_extra_classes_alongside_the_kind_class() { +async fn with_prop_adds_extra_classes_alongside_the_intent_class() { let mut badge = Badge::danger(Lc::n("Admin")).with_prop(PropsOp::add_classes("custom")); let html = badge.render(&mut Context::default()).await.into_string(); From d8f82ea1d251dc6b6a9cbcd889f7de950c6871f8 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 23 Aug 2026 13:39:38 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(response):=20Renombra?= =?UTF-8?q?=20Waypoint::as=5Fstr=20a=20as=5Fderef?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/response/waypoint.rs | 6 +++--- tests/waypoint.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/response/waypoint.rs b/src/response/waypoint.rs index b42c57c7..08d4f96e 100644 --- a/src/response/waypoint.rs +++ b/src/response/waypoint.rs @@ -75,7 +75,7 @@ impl Waypoint { } /// Devuelve la URL de destino, si se proporcionó una y es una ruta local válida. - pub fn as_str(&self) -> Option<&str> { + pub fn as_deref(&self) -> Option<&str> { self.waypoint.as_deref() } @@ -108,7 +108,7 @@ impl Waypoint { /// ``` pub fn append_to(&self, route: impl Into) -> RoutePath { let mut route = route.into(); - if let Some(d) = self.as_str() { + if let Some(d) = self.as_deref() { route.alter_param("waypoint", d); } route @@ -144,7 +144,7 @@ impl Waypoint { /// } /// ``` pub fn or(&self, fallback: impl Into) -> RoutePath { - match self.as_str() { + match self.as_deref() { Some(d) => RoutePath::new(d.to_owned()), None => fallback.into(), } diff --git a/tests/waypoint.rs b/tests/waypoint.rs index 76422e4e..40112b2e 100644 --- a/tests/waypoint.rs +++ b/tests/waypoint.rs @@ -6,7 +6,7 @@ use pagetop::prelude::*; async fn waypoint_accepts_local_paths() { for path in ["/", "/admin/users", "/admin/users?page=2"] { let w = Waypoint::from(path.to_owned()); - assert_eq!(w.as_str(), Some(path)); + assert_eq!(w.as_deref(), Some(path)); } } @@ -22,7 +22,7 @@ async fn waypoint_rejects_open_redirect_targets() { "javascript:alert(1)", ] { let w = Waypoint::from(target.to_owned()); - assert_eq!(w.as_str(), None, "expected {target:?} to be rejected"); + assert_eq!(w.as_deref(), None, "expected {target:?} to be rejected"); } } @@ -31,7 +31,7 @@ async fn waypoint_deserialize_rejects_open_redirect_targets() { // Reproduces the real entry point (`web::Query`): the value arrives via // deserialization, not through `Waypoint::from(String)` as in the previous test. let w: Waypoint = serde_json::from_str(r#"{"waypoint":"https://evil.example"}"#).unwrap(); - assert_eq!(w.as_str(), None); + assert_eq!(w.as_deref(), None); } // **< Waypoint::or() >***************************************************************************** From c8654a5742ec4c9a09657f936d19d513f312e3ed Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 24 Aug 2026 19:54:58 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20(core):=20A=C3=B1ade=20TypedOp=20para=20restringir=20Children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mismo repertorio de operaciones que `ChildOp`, pero cada variante exige un componente de tipo `C`, evitando que un componente ajeno a ese tipo acabe en una lista pensada para un único tipo de elemento. --- src/core/component.rs | 2 +- src/core/component/children.rs | 73 ++++++++++++++++++++++++ tests/component_children.rs | 100 +++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) diff --git a/src/core/component.rs b/src/core/component.rs index 07a00e30..fe26edb4 100644 --- a/src/core/component.rs +++ b/src/core/component.rs @@ -14,7 +14,7 @@ pub use definition::{Component, ComponentClone, ComponentRender}; mod children; pub use children::Children; -pub use children::{Child, ChildOp, Embed}; +pub use children::{Child, ChildOp, Embed, TypedOp}; mod message; pub use message::{MessageLevel, StatusMessage}; diff --git a/src/core/component/children.rs b/src/core/component/children.rs index 154b4945..6a716c72 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -258,6 +258,79 @@ pub enum ChildOp { Reset, } +/// Mismo repertorio de operaciones de [`ChildOp`] restringido a un tipo de componente. +/// +/// Conserva toda la funcionalidad de [`ChildOp`] (inserción relativa, reemplazo o eliminación por +/// `id`, etc.) sin permitir que un componente ajeno al tipo `C` acabe en una lista pensada para un +/// único tipo de elemento (p. ej., los elementos de un menú [`Nav`](crate::base::component::Nav)). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let nav = nav::Nav::new() +/// // Un componente `nav::Item` se convierte implícitamente en `TypedOp::Add`. +/// .with_item(nav::Item::link(Lc::n("Home"), "/")) +/// // Para el resto de operaciones se construye la variante explícita. +/// .with_item(TypedOp::AddMany(vec![ +/// nav::Item::link(Lc::n("About"), "/about"), +/// nav::Item::link(Lc::n("Contact"), "/contact"), +/// ])); +/// ``` +pub enum TypedOp { + /// Añade un componente al final de la lista. + Add(C), + /// Añade un componente sólo si la lista está vacía. + AddIfEmpty(C), + /// Añade varios componentes al final de la lista, en el orden recibido. + AddMany(Vec), + /// Inserta un componente justo después del que tiene el `id` dado, o al final si no existe. + InsertAfterId(&'static str, C), + /// Inserta un componente justo antes del que tiene el `id` dado, o al principio si no existe. + InsertBeforeId(&'static str, C), + /// Inserta un componente al principio de la lista. + Prepend(C), + /// Inserta varios componentes al principio de la lista, manteniendo el orden recibido. + PrependMany(Vec), + /// Elimina el primer componente con el `id` dado. + RemoveById(&'static str), + /// Sustituye el primer componente con el `id` dado por otro. + ReplaceById(&'static str, C), + /// Vacía la lista eliminando todos los componentes. + Reset, +} + +impl From for TypedOp { + /// Convierte un componente de tipo `C` en [`TypedOp::Add`], permitiendo pasarlo directamente a + /// métodos como `with_item()` sin envolverlo explícitamente. + #[inline] + fn from(component: C) -> Self { + TypedOp::Add(component) + } +} + +impl From> for ChildOp { + /// Traduce cada variante de [`TypedOp`] a su equivalente en [`ChildOp`], envolviendo cada + /// componente `C` en un [`Child`]. + fn from(op: TypedOp) -> Self { + match op { + TypedOp::Add(c) => ChildOp::Add(Child::with(c)), + TypedOp::AddIfEmpty(c) => ChildOp::AddIfEmpty(Child::with(c)), + TypedOp::AddMany(cs) => ChildOp::AddMany(cs.into_iter().map(Child::with).collect()), + TypedOp::InsertAfterId(id, c) => ChildOp::InsertAfterId(id, Child::with(c)), + TypedOp::InsertBeforeId(id, c) => ChildOp::InsertBeforeId(id, Child::with(c)), + TypedOp::Prepend(c) => ChildOp::Prepend(Child::with(c)), + TypedOp::PrependMany(cs) => { + ChildOp::PrependMany(cs.into_iter().map(Child::with).collect()) + } + TypedOp::RemoveById(id) => ChildOp::RemoveById(id), + TypedOp::ReplaceById(id, c) => ChildOp::ReplaceById(id, Child::with(c)), + TypedOp::Reset => ChildOp::Reset, + } + } +} + /// Lista ordenada de componentes hijo ([`Child`]) mantenida por un componente padre. /// /// Permite añadir, modificar, renderizar y consultar componentes hijo en orden de inserción, con diff --git a/tests/component_children.rs b/tests/component_children.rs index bce7d3da..36472a6a 100644 --- a/tests/component_children.rs +++ b/tests/component_children.rs @@ -269,6 +269,106 @@ async fn children_render_concatenates_all_outputs_in_order() { ); } +// **< TypedOp >************************************************************************************ +// +// `TypedOp` just translates to the equivalent `ChildOp` variant (see the `From` impl in +// `children.rs`), so these tests only check that each variant reaches the right `Children` +// operation (the operations themselves are already covered above under `ChildOp`). + +#[pagetop::test] +async fn typed_op_add_appends_component() { + let c = Children::new().with_child(TypedOp::Add(TestComp::text("a"))); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "a"); +} + +#[pagetop::test] +async fn typed_op_add_if_empty_only_adds_when_list_is_empty() { + let c = Children::new() + .with_child(TypedOp::AddIfEmpty(TestComp::text("first"))) + .with_child(TypedOp::AddIfEmpty(TestComp::text("second"))); + assert_eq!( + c.render(&mut Context::default()).await.into_string(), + "first" + ); +} + +#[pagetop::test] +async fn typed_op_add_many_appends_all_in_order() { + let c = Children::new().with_child(TypedOp::AddMany(vec![ + TestComp::text("x"), + TestComp::text("y"), + TestComp::text("z"), + ])); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "xyz"); +} + +#[pagetop::test] +async fn typed_op_insert_after_id_inserts_after_matching_element() { + let c = Children::new() + .with_child(TestComp::tagged("first", "a")) + .with_child(TestComp::text("c")) + .with_child(TypedOp::InsertAfterId("first", TestComp::text("b"))); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); +} + +#[pagetop::test] +async fn typed_op_insert_before_id_inserts_before_matching_element() { + let c = Children::new() + .with_child(TestComp::text("a")) + .with_child(TestComp::tagged("last", "c")) + .with_child(TypedOp::InsertBeforeId("last", TestComp::text("b"))); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); +} + +#[pagetop::test] +async fn typed_op_prepend_inserts_at_start() { + let c = Children::new() + .with_child(TestComp::text("b")) + .with_child(TypedOp::Prepend(TestComp::text("a"))); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "ab"); +} + +#[pagetop::test] +async fn typed_op_prepend_many_inserts_all_at_start() { + let c = Children::new() + .with_child(TestComp::text("c")) + .with_child(TypedOp::PrependMany(vec![ + TestComp::text("a"), + TestComp::text("b"), + ])); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "abc"); +} + +#[pagetop::test] +async fn typed_op_remove_by_id_removes_matching_element() { + let c = Children::new() + .with_child(TestComp::tagged("keep", "a")) + .with_child(TestComp::tagged("drop", "b")) + .with_child(TypedOp::::RemoveById("drop")); + assert_eq!(c.render(&mut Context::default()).await.into_string(), "a"); +} + +#[pagetop::test] +async fn typed_op_replace_by_id_replaces_matching_element() { + let c = Children::new() + .with_child(TestComp::tagged("target", "old")) + .with_child(TestComp::text("b")) + .with_child(TypedOp::ReplaceById("target", TestComp::text("new"))); + assert_eq!( + c.render(&mut Context::default()).await.into_string(), + "newb" + ); +} + +#[pagetop::test] +async fn typed_op_reset_clears_all_elements() { + let c = Children::new() + .with_child(TestComp::text("a")) + .with_child(TestComp::text("b")) + .with_child(TypedOp::::Reset); + assert!(c.is_empty()); +} + // **< Embed >************************************************************************************** #[pagetop::test] From 99a4837e1174379f958c2144f1f68522ff90522b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 24 Aug 2026 20:18:29 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20(button):=20A=C3=B1ade=20compon?= =?UTF-8?q?entes=20Button=20y=20ButtonSet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/basic.css | 97 +++++++++++ examples/form-controls.rs | 86 ++++----- examples/locale/en-US/form-controls.ftl | 2 + examples/locale/es-ES/form-controls.ftl | 2 + src/base/component.rs | 5 +- src/base/component/button.rs | 222 +----------------------- src/base/component/button/component.rs | 210 ++++++++++++++++++++++ src/base/component/button/props.rs | 44 +++++ src/base/component/button/set.rs | 73 ++++++++ tests/component_button.rs | 83 +++++++++ 10 files changed, 566 insertions(+), 258 deletions(-) create mode 100644 src/base/component/button/component.rs create mode 100644 src/base/component/button/props.rs create mode 100644 src/base/component/button/set.rs diff --git a/assets/css/basic.css b/assets/css/basic.css index 818db21a..1a3f5c18 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -87,6 +87,103 @@ body { .badge-warning { background-color: var(--val-color--warning); } .badge-danger { background-color: var(--val-color--danger); } +/* + * Buttons + */ + +.button { + display: inline-block; + padding: 0.5rem 1.25rem; + font-size: var(--val-fs--base); + font-weight: 600; + line-height: var(--val-lh--base); + color: #fff; + background-color: var(--val-color--primary); + border: 1px solid var(--val-color--primary); + border-radius: 0.375rem; + cursor: pointer; + transition: background-color .15s ease-in-out, border-color .15s ease-in-out; +} +.button:hover { + background-color: color-mix(in srgb, var(--val-color--primary) 85%, black); + border-color: color-mix(in srgb, var(--val-color--primary) 85%, black); +} +.button:disabled { + opacity: 0.65; + cursor: default; +} +.button::-moz-focus-inner { + border: 0; +} + +.button-primary, +.button-secondary, +.button-success, +.button-danger { + color: #fff; +} +.button-info, +.button-warning { + color: #000; +} +.button-primary { background-color: var(--val-color--primary); border-color: var(--val-color--primary); } +.button-secondary { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); } +.button-success { background-color: var(--val-color--success); border-color: var(--val-color--success); } +.button-info { background-color: var(--val-color--info); border-color: var(--val-color--info); } +.button-warning { background-color: var(--val-color--warning); border-color: var(--val-color--warning); } +.button-danger { background-color: var(--val-color--danger); border-color: var(--val-color--danger); } + +.button-primary:hover { background-color: color-mix(in srgb, var(--val-color--primary) 85%, black); border-color: color-mix(in srgb, var(--val-color--primary) 85%, black); } +.button-secondary:hover { background-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); border-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); } +.button-success:hover { background-color: color-mix(in srgb, var(--val-color--success) 85%, black); border-color: color-mix(in srgb, var(--val-color--success) 85%, black); } +.button-info:hover { background-color: color-mix(in srgb, var(--val-color--info) 85%, black); border-color: color-mix(in srgb, var(--val-color--info) 85%, black); } +.button-warning:hover { background-color: color-mix(in srgb, var(--val-color--warning) 85%, black); border-color: color-mix(in srgb, var(--val-color--warning) 85%, black); } +.button-danger:hover { background-color: color-mix(in srgb, var(--val-color--danger) 85%, black); border-color: color-mix(in srgb, var(--val-color--danger) 85%, black); } + +.button-outline-primary, +.button-outline-secondary, +.button-outline-success, +.button-outline-info, +.button-outline-warning, +.button-outline-danger { + background-color: transparent; +} +.button-outline-primary { border-color: var(--val-color--primary); color: var(--val-color--primary); } +.button-outline-secondary { border-color: var(--val-color--secondary); color: var(--val-color--secondary); } +.button-outline-success { border-color: var(--val-color--success); color: var(--val-color--success); } +.button-outline-info { border-color: var(--val-color--info); color: var(--val-color--info); } +.button-outline-warning { border-color: var(--val-color--warning); color: var(--val-color--warning); } +.button-outline-danger { border-color: var(--val-color--danger); color: var(--val-color--danger); } + +.button-outline-primary:hover { background-color: var(--val-color--primary); border-color: var(--val-color--primary); color: #fff; } +.button-outline-secondary:hover { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); color: #fff; } +.button-outline-success:hover { background-color: var(--val-color--success); border-color: var(--val-color--success); color: #fff; } +.button-outline-info:hover { background-color: var(--val-color--info); border-color: var(--val-color--info); color: #000; } +.button-outline-warning:hover { background-color: var(--val-color--warning); border-color: var(--val-color--warning); color: #000; } +.button-outline-danger:hover { background-color: var(--val-color--danger); border-color: var(--val-color--danger); color: #fff; } + +.button-link { + font-weight: 400; + color: var(--val-color--primary); + background-color: transparent; + border-color: transparent; + text-decoration: underline; +} +.button-link:hover { + color: color-mix(in srgb, var(--val-color--primary) 85%, black); + background-color: transparent; + border-color: transparent; +} +.button-link:disabled { + color: var(--val-color--text--muted); +} + +.button-set { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + /* * Form components */ diff --git a/examples/form-controls.rs b/examples/form-controls.rs index 1b495440..e40b7af0 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -138,21 +138,23 @@ async fn form_controls(request: HttpRequest) -> Result { ) // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-selections")) - // Botones de acción. - .with_child(Button::submit(Lc::t("btn_submit", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::solid( - ThemeColor::Primary, - )), - )) - .with_child(Button::reset(Lc::t("btn_reset", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::outline( - ThemeColor::Secondary, - )), - )) + // Botonera de acciones. .with_child( - Button::plain(Lc::t("btn_cancel", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::link()), - ), + button::ButtonSet::new() + .with_button( + Button::submit(Lc::t("btn_submit", &LOC)).with_style( + button::ButtonStyle::Solid(Intent::Primary), + ), + ) + .with_button( + Button::reset(Lc::t("btn_reset", &LOC)).with_style( + button::ButtonStyle::Outline(Intent::Secondary), + ), + ) + .with_button( + Button::plain(Lc::t("btn_cancel", &LOC)) + .with_style(button::ButtonStyle::Link), + ), ), ), ) @@ -252,21 +254,23 @@ async fn form_controls(request: HttpRequest) -> Result { ) // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-text")) - // Botones de acción. - .with_child(Button::submit(Lc::t("btn_submit", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::solid( - ThemeColor::Primary, - )), - )) - .with_child(Button::reset(Lc::t("btn_reset", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::outline( - ThemeColor::Secondary, - )), - )) + // Botonera de acciones. .with_child( - Button::plain(Lc::t("btn_cancel", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::link()), - ), + button::ButtonSet::new() + .with_button( + Button::submit(Lc::t("btn_submit", &LOC)).with_style( + button::ButtonStyle::Solid(Intent::Primary), + ), + ) + .with_button( + Button::reset(Lc::t("btn_reset", &LOC)).with_style( + button::ButtonStyle::Outline(Intent::Secondary), + ), + ) + .with_button( + Button::plain(Lc::t("btn_cancel", &LOC)) + .with_style(button::ButtonStyle::Link), + ), ), ), ) @@ -395,20 +399,20 @@ fn form_lists() -> Form { form // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-lists")) - // Botones de acción. + // Botonera de acciones. .with_child( - Button::submit(Lc::t("btn_submit", &LOC)).with_prop(PropsOp::add_classes( - class::ButtonColor::solid(ThemeColor::Primary), - )), - ) - .with_child( - Button::reset(Lc::t("btn_reset", &LOC)).with_prop(PropsOp::add_classes( - class::ButtonColor::outline(ThemeColor::Secondary), - )), - ) - .with_child( - Button::plain(Lc::t("btn_cancel", &LOC)) - .with_prop(PropsOp::add_classes(class::ButtonColor::link())), + button::ButtonSet::new() + .with_button( + Button::submit(Lc::t("btn_submit", &LOC)) + .with_style(button::ButtonStyle::Solid(Intent::Primary)), + ) + .with_button( + Button::reset(Lc::t("btn_reset", &LOC)) + .with_style(button::ButtonStyle::Outline(Intent::Secondary)), + ) + .with_button( + Button::plain(Lc::t("btn_cancel", &LOC)).with_style(button::ButtonStyle::Link), + ), ) } diff --git a/examples/locale/en-US/form-controls.ftl b/examples/locale/en-US/form-controls.ftl index ce6c76ca..05e345c6 100644 --- a/examples/locale/en-US/form-controls.ftl +++ b/examples/locale/en-US/form-controls.ftl @@ -75,3 +75,5 @@ help_rating = From 1 (very poor) to 10 (excellent). btn_submit = Submit btn_reset = Reset btn_cancel = Cancel +btn_ok = Ok +btn_delete = Delete record diff --git a/examples/locale/es-ES/form-controls.ftl b/examples/locale/es-ES/form-controls.ftl index 40781042..57ce3318 100644 --- a/examples/locale/es-ES/form-controls.ftl +++ b/examples/locale/es-ES/form-controls.ftl @@ -75,3 +75,5 @@ help_rating = De 1 (muy malo) a 10 (excelente). btn_submit = Enviar btn_reset = Restablecer btn_cancel = Cancelar +btn_ok = Aceptar +btn_delete = Eliminar registro diff --git a/src/base/component.rs b/src/base/component.rs index e01ed9af..fbabe173 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -15,8 +15,9 @@ pub use breadcrumb::Breadcrumb; mod block; pub use block::Block; -mod button; -pub use button::{Button, ButtonAction}; +pub mod button; +#[doc(inline)] +pub use button::{Button, ButtonSet}; pub mod container; #[doc(inline)] diff --git a/src/base/component/button.rs b/src/base/component/button.rs index 4ecc95d3..fdfc22ea 100644 --- a/src/base/component/button.rs +++ b/src/base/component/button.rs @@ -1,218 +1,10 @@ -use crate::prelude::*; +//! Definiciones para crear botones ([`Button`]) y conjuntos de botones ([`ButtonSet`]). -use std::fmt; +mod props; +pub use props::{ButtonKind, ButtonStyle}; -// **< ButtonAction >******************************************************************************* +mod component; +pub use component::Button; -/// Comportamiento de un [`Button`] al activarse. -#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] -pub enum ButtonAction { - /// Envía un formulario al servidor. Es el **tipo por defecto**. - #[default] - Submit, - /// Restablece todos los campos de un formulario a sus valores iniciales. - Reset, - /// Botón de propósito general, sin efecto predeterminado. Su comportamiento podría definirse - /// mediante JavaScript. - Plain, -} - -impl fmt::Display for ButtonAction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - ButtonAction::Submit => "submit", - ButtonAction::Reset => "reset", - ButtonAction::Plain => "button", - }) - } -} - -// **< Button >************************************************************************************* - -/// Componente para crear un **botón**. -/// -/// Renderiza un botón con soporte para las variantes disponibles en [`ButtonAction`] (`submit`, -/// `reset` y botón genérico). -/// -/// El comportamiento del botón se establece al crearlo: -/// -/// - [`Button::submit()`]: botón de envío (por defecto). -/// - [`Button::reset()`]: botón de restablecimiento de valores. -/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado. -/// -/// El botón puede usarse dentro o fuera de un formulario. -/// -/// # Ejemplo -/// -/// ```rust,no_run -/// use pagetop::prelude::*; -/// -/// let save = Button::submit(Lc::n("Save")); -/// let cancel = Button::plain(Lc::n("Cancel")); -/// let clear = Button::reset(Lc::n("Clear")); -/// ``` -/// -/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del -/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar -/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option`: -/// -/// ```rust,ignore -/// #[derive(serde::Deserialize)] -/// struct FormData { -/// #[serde(default)] -/// action: Option, // p. ej., "save" o "delete"; `None` si el botón no tenía `name`. -/// } -/// ``` -#[derive(AutoDefault, Clone, Debug, Getters)] -pub struct Button { - /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. - props: Props, - /// Devuelve el comportamiento del botón al activarse. - kind: ButtonAction, - /// Devuelve el nombre del botón. - name: AttrName, - /// Devuelve el valor del botón. - value: AttrValue, - /// Devuelve la etiqueta del botón. - label: Lc, - /// Devuelve el texto emergente del botón (atributo `title`). - title: Lc, - /// Devuelve si el botón recibe el foco automáticamente al cargar la página. - autofocus: bool, - /// Devuelve si el botón está deshabilitado. - disabled: bool, -} - -#[async_trait] -impl Component for Button { - fn new() -> Self { - Self::default() - } - - fn id(&self) -> Option { - self.props.get_id() - } - - fn setup(&mut self, _cx: &Context) { - self.alter_prop(PropsOp::prepend_classes("button")); - } - - async fn prepare(&self, cx: &mut Context) -> Result { - Ok(html! { - button - type=(self.kind()) - (self.props()) - name=[self.name().as_deref()] - value=[self.value().as_deref()] - title=[self.title().lookup(cx)] - autofocus[*self.autofocus()] - disabled[*self.disabled()] - { - @if let Some(label) = self.label().lookup(cx) { - (label) - } - } - }) - } -} - -impl Button { - /// Crea un botón de **envío** (`type="submit"`). - /// - /// Es la acción predeterminada al pulsar un botón en la mayoría de los formularios: envía los - /// datos al servidor. - pub fn submit(label: Lc) -> Self { - Self { - kind: ButtonAction::Submit, - label, - ..Default::default() - } - } - - /// Crea un botón de **restablecimiento** (`type="reset"`). - /// - /// Al pulsarlo, devuelve todos los campos del formulario a sus valores iniciales. - pub fn reset(label: Lc) -> Self { - Self { - kind: ButtonAction::Reset, - label, - ..Default::default() - } - } - - /// Crea un **botón genérico** (`type="button"`). - /// - /// No tiene un comportamiento predeterminado sobre el formulario. Su comportamiento puede - /// definirse mediante JavaScript. - pub fn plain(label: Lc) -> Self { - Self { - kind: ButtonAction::Plain, - label, - ..Default::default() - } - } - - // **< Button 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 botón (atributo `name`). - /// - /// Cuando el formulario tiene varios botones de envío, el navegador incluye en el envío el par - /// `name=value` sólo del botón que activó el formulario. Permite identificar cuál fue pulsado. - #[builder_fn] - pub fn with_name(mut self, name: impl AsRef) -> Self { - self.name.alter_name(name); - self - } - - /// Establece el valor del botón (atributo `value`). - /// - /// Es el dato que el navegador transmite al servidor junto con el `name` cuando este botón - /// activa el envío. Útil para distinguir entre varios botones de envío en un mismo formulario. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - self.value.alter_str(value); - self - } - - /// Establece la etiqueta visible del botón (usa [`Lc::none()`] para quitarla). - #[builder_fn] - pub fn with_label(mut self, label: Lc) -> Self { - self.label = label; - self - } - - /// Establece el texto emergente del botón (usa [`Lc::none()`] para quitarlo). - #[builder_fn] - pub fn with_title(mut self, title: Lc) -> Self { - self.title = title; - self - } - - /// Establece si el botón 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 botón está deshabilitado. - #[builder_fn] - pub fn with_disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } -} +mod set; +pub use set::ButtonSet; diff --git a/src/base/component/button/component.rs b/src/base/component/button/component.rs new file mode 100644 index 00000000..aef92e7d --- /dev/null +++ b/src/base/component/button/component.rs @@ -0,0 +1,210 @@ +use crate::prelude::*; + +/// Componente para crear un **botón**. +/// +/// Renderiza un botón con soporte para las variantes: +/// +/// - [`Button::submit()`]: botón de envío (por defecto). +/// - [`Button::reset()`]: botón de restablecimiento de valores. +/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado. +/// +/// Un botón puede usarse dentro o fuera de un formulario. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let save = Button::submit(Lc::n("Save")); +/// let cancel = Button::plain(Lc::n("Cancel")); +/// let clear = Button::reset(Lc::n("Clear")); +/// ``` +/// +/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del +/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar +/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option`: +/// +/// ```rust,ignore +/// #[derive(serde::Deserialize)] +/// struct FormData { +/// #[serde(default)] +/// action: Option, // p. ej., "save" o "delete"; `None` si el botón no tenía `name`. +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Button { + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. + props: Props, + /// Devuelve el comportamiento del botón al activarse. + kind: button::ButtonKind, + /// Devuelve el estilo visual del botón. + #[getters(copy)] + style: button::ButtonStyle, + /// Devuelve el nombre del botón. + name: AttrName, + /// Devuelve el valor del botón. + value: AttrValue, + /// Devuelve la etiqueta del botón. + label: Lc, + /// Devuelve el texto emergente del botón (atributo `title`). + title: Lc, + /// Devuelve si el botón recibe el foco automáticamente al cargar la página. + autofocus: bool, + /// Devuelve si el botón está deshabilitado. + disabled: bool, +} + +#[async_trait] +impl Component for Button { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + use button::ButtonStyle; + + self.alter_prop(PropsOp::prepend_classes(match self.style() { + ButtonStyle::None => "button".to_string(), + ButtonStyle::Solid(intent) => util::join!("button button-", intent.as_str()), + ButtonStyle::Outline(intent) => util::join!("button button-outline-", intent.as_str()), + ButtonStyle::Link => "button button-link".to_string(), + })); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + Ok(html! { + button + type=(self.kind()) + (self.props()) + name=[self.name().as_deref()] + value=[self.value().as_deref()] + title=[self.title().lookup(cx)] + autofocus[*self.autofocus()] + disabled[*self.disabled()] + { + @if let Some(label) = self.label().lookup(cx) { + (label) + } + } + }) + } +} + +impl Button { + /// Crea un botón de **envío** (`type="submit"`). + /// + /// Es la acción predeterminada al pulsar un botón en la mayoría de los formularios: envía los + /// datos al servidor. + pub fn submit(label: Lc) -> Self { + Self { + kind: button::ButtonKind::Submit, + label, + ..Default::default() + } + } + + /// Crea un botón de **restablecimiento** (`type="reset"`). + /// + /// Al pulsarlo, devuelve todos los campos del formulario a sus valores iniciales. + pub fn reset(label: Lc) -> Self { + Self { + kind: button::ButtonKind::Reset, + label, + ..Default::default() + } + } + + /// Crea un **botón genérico** (`type="button"`). + /// + /// No tiene un comportamiento predeterminado sobre el formulario. Su comportamiento puede + /// definirse mediante JavaScript. + pub fn plain(label: Lc) -> Self { + Self { + kind: button::ButtonKind::Plain, + label, + ..Default::default() + } + } + + // **< Button 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 comportamiento del botón al activarse. + #[builder_fn] + pub fn with_kind(mut self, kind: button::ButtonKind) -> Self { + self.kind = kind; + self + } + + /// Establece el estilo visual del botón (usa [`button::ButtonStyle::None`] para quitarlo). + #[builder_fn] + pub fn with_style(mut self, style: button::ButtonStyle) -> Self { + self.style = style; + self + } + + /// Establece el nombre del botón (atributo `name`). + /// + /// Cuando el formulario tiene varios botones de envío, el navegador incluye en el envío el par + /// `name=value` sólo del botón que activó el formulario. Permite identificar cuál fue pulsado. + #[builder_fn] + pub fn with_name(mut self, name: impl AsRef) -> Self { + self.name.alter_name(name); + self + } + + /// Establece el valor del botón (atributo `value`). + /// + /// Es el dato que el navegador transmite al servidor junto con el `name` cuando este botón + /// activa el envío. Útil para distinguir entre varios botones de envío en un mismo formulario. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + self.value.alter_str(value); + self + } + + /// Establece la etiqueta visible del botón (usa [`Lc::none()`] para quitarla). + #[builder_fn] + pub fn with_label(mut self, label: Lc) -> Self { + self.label = label; + self + } + + /// Establece el texto emergente del botón (usa [`Lc::none()`] para quitarlo). + #[builder_fn] + pub fn with_title(mut self, title: Lc) -> Self { + self.title = title; + self + } + + /// Establece si el botón 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 botón está deshabilitado. + #[builder_fn] + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} diff --git a/src/base/component/button/props.rs b/src/base/component/button/props.rs new file mode 100644 index 00000000..483e9b86 --- /dev/null +++ b/src/base/component/button/props.rs @@ -0,0 +1,44 @@ +use crate::prelude::*; + +use std::fmt; + +// **< ButtonStyle >******************************************************************************** + +/// Estilo visual de un [`Button`](super::Button). +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum ButtonStyle { + /// Sin clase de estilo (estilo por defecto del tema). + #[default] + None, + /// Botón sólido: genera la clase `button-{color}`. + Solid(Intent), + /// Botón con contorno: genera la clase `button-outline-{color}`. + Outline(Intent), + /// Botón tipo enlace: genera la clase `button-link`. + Link, +} + +// **< ButtonKind >********************************************************************************* + +/// Comportamiento de un [`Button`](super::Button) al activarse. +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum ButtonKind { + /// Envía un formulario al servidor. Es el **tipo por defecto**. + #[default] + Submit, + /// Restablece todos los campos de un formulario a sus valores iniciales. + Reset, + /// Botón de propósito general, sin efecto predeterminado. Su comportamiento podría definirse + /// mediante JavaScript. + Plain, +} + +impl fmt::Display for ButtonKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + ButtonKind::Submit => "submit", + ButtonKind::Reset => "reset", + ButtonKind::Plain => "button", + }) + } +} diff --git a/src/base/component/button/set.rs b/src/base/component/button/set.rs new file mode 100644 index 00000000..a0e5de8c --- /dev/null +++ b/src/base/component/button/set.rs @@ -0,0 +1,73 @@ +use crate::prelude::*; + +/// Componente para mostrar un **conjunto de botones**. +/// +/// Envuelve los botones en un contenedor que cada tema estiliza para separarlos visualmente. Sólo +/// admite componentes [`Button`]. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let actions = button::ButtonSet::new() +/// .with_button(Button::submit(Lc::n("Save"))) +/// .with_button(Button::plain(Lc::n("Cancel"))); +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct ButtonSet { + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. + props: Props, + /// Devuelve los botones del conjunto. + buttons: Children, +} + +#[async_trait] +impl Component for ButtonSet { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + self.alter_prop(PropsOp::prepend_classes("button-set")); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let buttons = self.buttons().render(cx).await; + if buttons.is_empty() { + return Ok(html! {}); + } + Ok(html! { + div (self.props()) { (buttons) } + }) + } +} + +impl ButtonSet { + // **< ButtonSet 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 + } + + /// Añade un botón al conjunto, o modifica su lista de botones con una operación [`TypedOp`]. + #[builder_fn] + pub fn with_button(mut self, op: impl Into>) -> Self { + self.buttons.alter_child(op.into()); + self + } +} diff --git a/tests/component_button.rs b/tests/component_button.rs index 5918d7c1..27b260f1 100644 --- a/tests/component_button.rs +++ b/tests/component_button.rs @@ -1,5 +1,7 @@ use pagetop::prelude::*; +// **< Button >************************************************************************************* + #[pagetop::test] async fn label_is_rendered_when_set() { let mut button = Button::submit(Lc::n("Save")); @@ -43,3 +45,84 @@ async fn title_attribute_can_be_cleared_with_lc_none() { assert!(!html.contains("title=")); } + +#[pagetop::test] +async fn style_class_reflects_intent_and_style() { + let mut button = + Button::submit(Lc::n("Save")).with_style(button::ButtonStyle::Solid(Intent::Danger)); + let html = button.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("button-danger")); +} + +#[pagetop::test] +async fn outline_style_generates_outline_class() { + let mut button = + Button::submit(Lc::n("Save")).with_style(button::ButtonStyle::Outline(Intent::Primary)); + let html = button.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("button-outline-primary")); +} + +#[pagetop::test] +async fn link_style_generates_link_class_without_intent() { + let mut button = Button::plain(Lc::n("Cancel")).with_style(button::ButtonStyle::Link); + let html = button.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("button-link")); +} + +// **< ButtonSet >********************************************************************************** + +#[pagetop::test] +async fn button_set_is_not_rendered_when_empty() { + let mut set = button::ButtonSet::new(); + let html = set.render(&mut Context::default()).await; + + assert!(html.is_empty()); +} + +#[pagetop::test] +async fn button_set_wraps_buttons_in_button_set_class() { + let mut set = button::ButtonSet::new().with_button(Button::submit(Lc::n("Save"))); + let html = set.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("button-set")); + assert!(html.contains("Save")); +} + +#[pagetop::test] +async fn button_set_renders_buttons_in_insertion_order() { + let mut set = button::ButtonSet::new() + .with_button(Button::submit(Lc::n("First"))) + .with_button(Button::plain(Lc::n("Second"))); + let html = set.render(&mut Context::default()).await.into_string(); + + assert!(html.find("First").unwrap() < html.find("Second").unwrap()); +} + +#[pagetop::test] +async fn button_set_add_many_appends_all_buttons() { + let mut set = button::ButtonSet::new().with_button(TypedOp::AddMany(vec![ + Button::submit(Lc::n("Save")), + Button::reset(Lc::n("Reset")), + Button::plain(Lc::n("Cancel")), + ])); + let html = set.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("Save")); + assert!(html.contains("Reset")); + assert!(html.contains("Cancel")); +} + +#[pagetop::test] +async fn button_set_remove_by_id_drops_matching_button() { + let mut set = button::ButtonSet::new() + .with_button(Button::submit(Lc::n("Save")).with_id("save-button")) + .with_button(Button::plain(Lc::n("Cancel"))) + .with_button(TypedOp::RemoveById("save-button")); + let html = set.render(&mut Context::default()).await.into_string(); + + assert!(!html.contains("Save")); + assert!(html.contains("Cancel")); +}