diff --git a/assets/css/basic.css b/assets/css/basic.css index 1a3f5c18..3e199708 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -44,23 +44,6 @@ 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 */ @@ -87,103 +70,6 @@ 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 */ @@ -394,26 +280,6 @@ 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/examples/form-controls.rs b/examples/form-controls.rs index e40b7af0..1b495440 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -138,23 +138,21 @@ async fn form_controls(request: HttpRequest) -> Result { ) // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-selections")) - // Botonera de acciones. + // 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, + )), + )) .with_child( - 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), - ), + Button::plain(Lc::t("btn_cancel", &LOC)).with_prop( + PropsOp::add_classes(class::ButtonColor::link()), + ), ), ), ) @@ -254,23 +252,21 @@ async fn form_controls(request: HttpRequest) -> Result { ) // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-text")) - // Botonera de acciones. + // 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, + )), + )) .with_child( - 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), - ), + Button::plain(Lc::t("btn_cancel", &LOC)).with_prop( + PropsOp::add_classes(class::ButtonColor::link()), + ), ), ), ) @@ -399,20 +395,20 @@ fn form_lists() -> Form { form // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-lists")) - // Botonera de acciones. + // Botones de acción. .with_child( - 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), - ), + 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())), ) } diff --git a/examples/locale/en-US/form-controls.ftl b/examples/locale/en-US/form-controls.ftl index 05e345c6..ce6c76ca 100644 --- a/examples/locale/en-US/form-controls.ftl +++ b/examples/locale/en-US/form-controls.ftl @@ -75,5 +75,3 @@ 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 57ce3318..40781042 100644 --- a/examples/locale/es-ES/form-controls.ftl +++ b/examples/locale/es-ES/form-controls.ftl @@ -75,5 +75,3 @@ 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/extensions/pagetop-bootsier/src/theme/bs.rs b/extensions/pagetop-bootsier/src/theme/bs.rs index 8fabce2b..ae101e55 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; +pub use badge::{Badge, BadgeKind}; // Button. mod button; diff --git a/extensions/pagetop-bootsier/src/theme/bs/badge.rs b/extensions/pagetop-bootsier/src/theme/bs/badge.rs index 43dceae8..615119c9 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/badge.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/badge.rs @@ -1,14 +1,18 @@ use pagetop::prelude::*; -pub use pagetop::base::component::Badge; +pub use pagetop::base::component::{Badge, BadgeKind}; // **< Badge SETUP >******************************************************************************** +#[rustfmt::skip] pub(crate) fn setup(badge: &mut Badge) { - let intent = badge.intent().as_str(); - - badge.alter_prop(PropsOp::replace_classes( - util::join!("badge-", intent), - util::join!("text-bg-", intent), - )); + 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)); } diff --git a/src/base/component.rs b/src/base/component.rs index fbabe173..53ccafdf 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -3,7 +3,7 @@ pub mod layout; mod badge; -pub use badge::Badge; +pub use badge::{Badge, BadgeKind}; mod brand; pub use brand::Brand; @@ -15,9 +15,8 @@ pub use breadcrumb::Breadcrumb; mod block; pub use block::Block; -pub mod button; -#[doc(inline)] -pub use button::{Button, ButtonSet}; +mod button; +pub use button::{Button, ButtonAction}; pub mod container; #[doc(inline)] diff --git a/src/base/component/badge.rs b/src/base/component/badge.rs index a01a61c5..12fa7ca8 100644 --- a/src/base/component/badge.rs +++ b/src/base/component/badge.rs @@ -1,12 +1,28 @@ 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_intent(Intent::Danger); +/// let badge = Badge::labeled(Lc::n("Admin")).with_kind(BadgeKind::Danger); /// /// // Equivalente usando el constructor directo del tipo. /// let badge = Badge::danger(Lc::n("Admin")); @@ -17,10 +33,9 @@ pub struct Badge { props: Props, /// Devuelve la etiqueta del badge. label: Lc, - /// Devuelve la intención semántica del badge. + /// Devuelve el tipo del badge. #[getters(copy)] - #[default(Intent::Secondary)] - intent: Intent, + kind: BadgeKind, } #[async_trait] @@ -33,11 +48,16 @@ impl Component for Badge { self.props.get_id() } + #[rustfmt::skip] fn setup(&mut self, _cx: &Context) { - self.alter_prop(PropsOp::prepend_classes(util::join!( - "badge badge-", - self.intent().as_str() - ))); + 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", + })); } async fn prepare(&self, cx: &mut Context) -> Result { @@ -50,7 +70,7 @@ impl Component for Badge { } impl Badge { - /// Crea un badge predeterminado (`Intent::default()`) con la etiqueta indicada. + /// Crea un badge predeterminado (`BadgeKind::default()`) con la etiqueta indicada. pub fn labeled(label: Lc) -> Self { Self { label, @@ -62,7 +82,7 @@ impl Badge { pub fn primary(label: Lc) -> Self { Self { label, - intent: Intent::Primary, + kind: BadgeKind::Primary, ..Default::default() } } @@ -71,7 +91,7 @@ impl Badge { pub fn secondary(label: Lc) -> Self { Self { label, - intent: Intent::Secondary, + kind: BadgeKind::Secondary, ..Default::default() } } @@ -80,7 +100,7 @@ impl Badge { pub fn success(label: Lc) -> Self { Self { label, - intent: Intent::Success, + kind: BadgeKind::Success, ..Default::default() } } @@ -89,7 +109,7 @@ impl Badge { pub fn info(label: Lc) -> Self { Self { label, - intent: Intent::Info, + kind: BadgeKind::Info, ..Default::default() } } @@ -98,7 +118,7 @@ impl Badge { pub fn warning(label: Lc) -> Self { Self { label, - intent: Intent::Warning, + kind: BadgeKind::Warning, ..Default::default() } } @@ -107,7 +127,7 @@ impl Badge { pub fn danger(label: Lc) -> Self { Self { label, - intent: Intent::Danger, + kind: BadgeKind::Danger, ..Default::default() } } @@ -135,10 +155,10 @@ impl Badge { self } - /// Establece la intención semántica del badge. + /// Establece el tipo del badge. #[builder_fn] - pub fn with_intent(mut self, intent: Intent) -> Self { - self.intent = intent; + pub fn with_kind(mut self, kind: BadgeKind) -> Self { + self.kind = kind; self } } diff --git a/src/base/component/brand.rs b/src/base/component/brand.rs index a1113ef3..fa7398b1 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 y un título, típicamente en la cabecera de la página o dentro de una barra de -/// navegació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. /// /// - Si hay ruta ([`with_route()`]), el bloque completo actúa como enlace. Por defecto enlaza a la /// raíz del sitio (`/`). -/// - 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. +/// - 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. /// /// # Ejemplo /// @@ -17,13 +17,15 @@ use crate::prelude::*; /// use pagetop::prelude::*; /// /// let brand = Brand::new() -/// .with_image(image::Source::logo(PageTopSvg::Color)) +/// .with_image(Some(Image::with(image::Source::logo(PageTopSvg::Color)))) /// .with_title(Lc::n("PageTop")) /// .with_route(Route::from("/")); /// ``` /// /// [`with_route()`]: Self::with_route -/// [`global::SETTINGS.app.name`]: crate::global::App::name +/// [`with_image()`]: Self::with_image +/// [`with_title()`]: Self::with_title +/// [`with_slogan()`]: Self::with_slogan #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Brand { /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. @@ -33,6 +35,8 @@ 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, @@ -55,23 +59,15 @@ 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); - let inner_brand = html! { - (image) - @if !title.is_empty() { - @if !image.is_empty() { - (" ") - } - span class="brand-title" { (title) } - } - }; - if inner_brand.is_empty() { + if image.is_empty() && title.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)) { (inner_brand) } + a (self.props()) href=(route.resolve(cx)) { (image) (title) (slogan) } } @else { - span (self.props()) { (inner_brand) } + span (self.props()) { (image) (title) (slogan) } } }) } @@ -108,6 +104,13 @@ 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/button.rs b/src/base/component/button.rs index fdfc22ea..4ecc95d3 100644 --- a/src/base/component/button.rs +++ b/src/base/component/button.rs @@ -1,10 +1,218 @@ -//! Definiciones para crear botones ([`Button`]) y conjuntos de botones ([`ButtonSet`]). +use crate::prelude::*; -mod props; -pub use props::{ButtonKind, ButtonStyle}; +use std::fmt; -mod component; -pub use component::Button; +// **< ButtonAction >******************************************************************************* -mod set; -pub use set::ButtonSet; +/// 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 + } +} diff --git a/src/base/component/button/component.rs b/src/base/component/button/component.rs deleted file mode 100644 index aef92e7d..00000000 --- a/src/base/component/button/component.rs +++ /dev/null @@ -1,210 +0,0 @@ -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 deleted file mode 100644 index 483e9b86..00000000 --- a/src/base/component/button/props.rs +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index a0e5de8c..00000000 --- a/src/base/component/button/set.rs +++ /dev/null @@ -1,73 +0,0 @@ -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/src/base/component/image/component.rs b/src/base/component/image/component.rs index 344df838..220a079e 100644 --- a/src/base/component/image/component.rs +++ b/src/base/component/image/component.rs @@ -49,13 +49,6 @@ 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 => {} @@ -79,9 +72,18 @@ 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(svg) => { - let label = (!alt_text.is_empty()).then_some(alt_text.as_str()); - return Ok(svg.markup_with(self.props(), label)); + 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::Responsive(source) => Some(source), image::Source::Thumbnail(source) => Some(source), @@ -142,19 +144,3 @@ 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 e339fd27..f2c306c1 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()) + (PageTopSvg::Color.markup(cx)) } } } @@ -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()) + (PageTopSvg::LineLight.markup(cx)) } div class="intro-footer-links" { a href="https://crates.io/crates/pagetop" target="_blank" rel="noopener noreferrer" { ("Crates.io") } diff --git a/src/core/component.rs b/src/core/component.rs index fe26edb4..07a00e30 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, TypedOp}; +pub use children::{Child, ChildOp, Embed}; mod message; pub use message::{MessageLevel, StatusMessage}; diff --git a/src/core/component/children.rs b/src/core/component/children.rs index 6a716c72..154b4945 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -258,79 +258,6 @@ 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/src/core/theme.rs b/src/core/theme.rs index 09dd0a2e..bad50216 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -117,8 +117,8 @@ //! //! [`ReservedRegions`]: crate::response::ReservedRegions -mod intent; -pub use intent::Intent; +mod color; +pub use color::{ColorName, CoreColors, IntoColor}; mod layout; pub use layout::{CoreRegions, RegionName, RegionRef}; diff --git a/src/core/theme/color.rs b/src/core/theme/color.rs new file mode 100644 index 00000000..62cf5cd0 --- /dev/null +++ b/src/core/theme/color.rs @@ -0,0 +1,68 @@ +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 deleted file mode 100644 index ff6e4626..00000000 --- a/src/core/theme/intent.rs +++ /dev/null @@ -1,31 +0,0 @@ -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 2953849c..0aafca55 100644 --- a/src/html/logo.rs +++ b/src/html/logo.rs @@ -1,5 +1,7 @@ use crate::AutoDefault; -use crate::html::{Markup, Props, html}; +use crate::core::component::Context; +use crate::html::{Markup, html}; +use crate::locale::Lc; /// Representación SVG del **logotipo de PageTop** para incrustar en HTML. /// @@ -7,19 +9,19 @@ use crate::html::{Markup, Props, html}; /// /// ```rust,no_run /// # use pagetop::prelude::*; -/// fn render_logo() -> Markup { +/// fn render_logo(cx: &mut Context) -> Markup { /// html! { /// div class="logo_color" { -/// (PageTopSvg::Color.markup()) +/// (PageTopSvg::Color.markup(cx)) /// } /// div class="line_dark" { -/// (PageTopSvg::LineDark.markup()) +/// (PageTopSvg::LineDark.markup(cx)) /// } /// div class="line_light" { -/// (PageTopSvg::LineLight.markup()) +/// (PageTopSvg::LineLight.markup(cx)) /// } /// div class="line_red" { -/// (PageTopSvg::LineRGB(255, 0, 0).markup()) +/// (PageTopSvg::LineRGB(255, 0, 0).markup(cx)) /// } /// } /// }; @@ -40,60 +42,29 @@ pub enum PageTopSvg { impl PageTopSvg { /// Devuelve el marcado SVG del logotipo según la variante elegida. - pub fn markup(&self) -> Markup { + 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), + }; html! { svg viewBox="0 0 1614 1614" xmlns="http://www.w3.org/2000/svg" role="img" - aria-label="PageTop" + aria-label=[Lc::l("pagetop_logo").lookup(cx)] preserveAspectRatio="xMidYMid slice" focusable="false" { - (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()) + (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 885eb841..3f4c0064 100644 --- a/src/locale/en-US/theme.ftl +++ b/src/locale/en-US/theme.ftl @@ -2,7 +2,9 @@ region_header = Header region_content = Content region_footer = Footer -region_aside = Aside + +# Logo. +pagetop_logo = PageTop Logo # Error Messages. error_code = Error { $code } diff --git a/src/locale/es-ES/theme.ftl b/src/locale/es-ES/theme.ftl index d77ee3c9..7d4abcf6 100644 --- a/src/locale/es-ES/theme.ftl +++ b/src/locale/es-ES/theme.ftl @@ -2,7 +2,9 @@ region_header = Cabecera region_content = Contenido region_footer = Pie de página -region_aside = Barra lateral + +# Logo. +pagetop_logo = Logotipo de PageTop # Error Messages. error_code = Error { $code } diff --git a/src/response/waypoint.rs b/src/response/waypoint.rs index 08d4f96e..b42c57c7 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_deref(&self) -> Option<&str> { + pub fn as_str(&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_deref() { + if let Some(d) = self.as_str() { route.alter_param("waypoint", d); } route @@ -144,7 +144,7 @@ impl Waypoint { /// } /// ``` pub fn or(&self, fallback: impl Into) -> RoutePath { - match self.as_deref() { + match self.as_str() { Some(d) => RoutePath::new(d.to_owned()), None => fallback.into(), } diff --git a/tests/component_badge.rs b/tests/component_badge.rs index 2d039e3b..f148c023 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_intent_is_secondary() { +async fn default_kind_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_intent_is_secondary() { } #[pagetop::test] -async fn each_direct_constructor_sets_its_intent() { +async fn each_direct_constructor_sets_its_kind() { 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_intent() { } #[pagetop::test] -async fn with_intent_overrides_the_default() { - let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger); +async fn with_kind_overrides_the_default() { + let mut badge = Badge::labeled(Lc::n("Admin")).with_kind(BadgeKind::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_intent() { +async fn direct_constructor_is_equivalent_to_labeled_with_kind() { let mut from_constructor = Badge::danger(Lc::n("Admin")); - let mut from_builder = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger); + let mut from_builder = Badge::labeled(Lc::n("Admin")).with_kind(BadgeKind::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_intent_class() { +async fn with_prop_adds_extra_classes_alongside_the_kind_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(); diff --git a/tests/component_button.rs b/tests/component_button.rs index 27b260f1..5918d7c1 100644 --- a/tests/component_button.rs +++ b/tests/component_button.rs @@ -1,7 +1,5 @@ use pagetop::prelude::*; -// **< Button >************************************************************************************* - #[pagetop::test] async fn label_is_rendered_when_set() { let mut button = Button::submit(Lc::n("Save")); @@ -45,84 +43,3 @@ 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")); -} diff --git a/tests/component_children.rs b/tests/component_children.rs index 36472a6a..bce7d3da 100644 --- a/tests/component_children.rs +++ b/tests/component_children.rs @@ -269,106 +269,6 @@ 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] diff --git a/tests/waypoint.rs b/tests/waypoint.rs index 40112b2e..76422e4e 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_deref(), Some(path)); + assert_eq!(w.as_str(), 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_deref(), None, "expected {target:?} to be rejected"); + assert_eq!(w.as_str(), 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_deref(), None); + assert_eq!(w.as_str(), None); } // **< Waypoint::or() >*****************************************************************************