diff --git a/assets/css/basic.css b/assets/css/basic.css index 2c69d35c..47cb26e9 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -81,11 +81,11 @@ body { color: #000; } .badge-primary { background-color: var(--val-color--primary); } -.badge-secondary { background-color: var(--val-color--secondary); } +.badge-neutral { background-color: var(--val-color--secondary); } .badge-success { background-color: var(--val-color--success); } .badge-info { background-color: var(--val-color--info); } .badge-warning { background-color: var(--val-color--warning); } -.badge-danger { background-color: var(--val-color--danger); } +.badge-severe { background-color: var(--val-color--danger); } /* * Buttons @@ -117,9 +117,9 @@ body { } .button-primary, -.button-secondary, +.button-neutral, .button-success, -.button-danger { +.button-severe { color: #fff; } .button-info, @@ -127,40 +127,40 @@ body { 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-neutral { 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-severe { 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-neutral: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-severe: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-neutral, .button-outline-success, .button-outline-info, .button-outline-warning, -.button-outline-danger { +.button-outline-severe { 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-neutral { 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-severe { 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-neutral: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-outline-severe:hover { background-color: var(--val-color--danger); border-color: var(--val-color--danger); color: #fff; } .button-link { font-weight: 400; diff --git a/src/base/component/badge.rs b/src/base/component/badge.rs index a01a61c5..1a7c45a0 100644 --- a/src/base/component/badge.rs +++ b/src/base/component/badge.rs @@ -6,10 +6,10 @@ use crate::prelude::*; /// /// ```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_intent(Intent::Severe); /// /// // Equivalente usando el constructor directo del tipo. -/// let badge = Badge::danger(Lc::n("Admin")); +/// let badge = Badge::severe(Lc::n("Admin")); /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Badge { @@ -19,7 +19,7 @@ pub struct Badge { label: Lc, /// Devuelve la intención semántica del badge. #[getters(copy)] - #[default(Intent::Secondary)] + #[default(Intent::Neutral)] intent: Intent, } @@ -33,10 +33,10 @@ impl Component for Badge { self.props.get_id() } - fn setup(&mut self, _cx: &Context) { + fn setup(&mut self, cx: &Context) { self.alter_prop(PropsOp::prepend_classes(util::join!( "badge badge-", - self.intent().as_str() + self.intent().color(cx) ))); } @@ -67,11 +67,11 @@ impl Badge { } } - /// Crea un badge de tipo *secondary* con la etiqueta indicada. - pub fn secondary(label: Lc) -> Self { + /// Crea un badge de tipo *neutral* con la etiqueta indicada. + pub fn neutral(label: Lc) -> Self { Self { label, - intent: Intent::Secondary, + intent: Intent::Neutral, ..Default::default() } } @@ -103,11 +103,11 @@ impl Badge { } } - /// Crea un badge de tipo *danger* con la etiqueta indicada. - pub fn danger(label: Lc) -> Self { + /// Crea un badge de tipo *severe* con la etiqueta indicada. + pub fn severe(label: Lc) -> Self { Self { label, - intent: Intent::Danger, + intent: Intent::Severe, ..Default::default() } } diff --git a/src/core/theme.rs b/src/core/theme.rs index 09dd0a2e..1e8cba33 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -13,12 +13,14 @@ //! //! # Temas hijo, herencia y componentes //! -//! PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un tema -//! hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos selectivamente. -//! Esta herencia sólo determina qué implementación de sus métodos se usa cuando el tema hijo no los -//! sobrescribe (como el renderizado del `` y del ``, los recursos incorporados, el uso -//! de [`Theme::handle_component()`], las páginas de error, etc.). Un tema hijo puede ser a su vez -//! padre de otro, basta declararlo cada vez con [`Theme::parent()`]. +//! PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre, +//! identificado por [`Theme::parent()`]. Un tema hijo hereda automáticamente todos los métodos del +//! padre y puede sobrescribirlos selectivamente. Esta herencia determina qué implementación de sus +//! métodos se usa cuando el tema hijo no los sobrescribe (ya sea el renderizado del `` o del +//! ``, la definición de los recursos necesarios, la asignación de colores por intención vía +//! [`Theme::intent_color()`], la captura de componentes con [`Theme::handle_component()`], las +//! páginas de error, etc.). Un tema hijo puede ser a su vez padre de otro, basta declararlo cada +//! vez con [`Theme::parent()`]. //! //! Como `parent()` se resuelve en tiempo de ejecución, PageTop no puede descartar en compilación //! referencias circulares (un tema acaba siendo padre de sí mismo, directa o transitivamente). Ese @@ -48,8 +50,8 @@ //! devuelva `Some(&Self)`. Basta con un `impl Theme for MyTheme {}` vacío, ya que todos los //! métodos de [`Theme`] tienen implementación por defecto. //! -//! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por -//! defecto no basta: +//! Un tema puede personalizarse en cinco pasos, cada uno necesario sólo si lo que ofrece PageTop +//! por defecto no basta: //! //! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegions`] (`Header`, `Aside`, //! `Content`, `Footer`) como regiones de plantilla siempre disponibles, y [`ReservedRegions`] @@ -75,6 +77,21 @@ //! ejemplo, [`CoreTemplates`] o el propio *enum* del tema). `pagetop-bootsier` hace exactamente //! esto para maquetar `Standard` y `Admin` de forma distinta, sin necesitar sus propias //! variantes de plantilla. +//! 4. **Traducir [`Intent`] a la paleta de colores propia del tema** sobrescribiendo +//! [`Theme::intent_color()`]. Por defecto, este método devuelve el vocabulario semántico de +//! [`Intent`] (`"primary"`, `"severe"`, etc.); un tema con su propio catálogo de colores (por +//! ejemplo, uno basado en Bootstrap) debe traducir cada variante al nombre que le corresponda en +//! su paleta. Los componentes que generan clases CSS a partir de una [`Intent`] (`Button`, +//! `Badge`, `Dropdown`, etc.) consultan este método a través de [`Intent::color()`], así que la +//! clase resultante ya nace en la paleta del tema activo. +//! 5. **Reexportar, extender o añadir componentes**. Un tema puede reexportar tal cual los +//! componentes propios de PageTop que no requieran adaptación, extenderlos con un trait propio +//! para añadir métodos exclusivos (guardando su estado en valores extra con +//! [`PropsOp::set_extra()`](crate::html::PropsOp::set_extra) para consumirlos en el +//! `setup()`/`render()` vía [`Theme::handle_component()`]), o aportar componentes propios. +//! `pagetop-bootsier` combina las tres estrategias: reexporta `Form`/`Fieldset` sin cambios, +//! extiende `Button`/`Badge`/`Dropdown`/`Nav`/`Navbar` con sus propios traits (`ButtonBootsier`, +//! `BadgeBootsier`, etc.), y añade componentes propios como `Offcanvas`. //! //! Para forzar una plantilla completamente distinta en una página concreta, se puede llamar //! manualmente a [`with_template()`](crate::core::component::Contextual::with_template). @@ -86,7 +103,7 @@ //! plantilla distinta. //! //! El resto del comportamiento de un tema (por ejemplo, el renderizado del ``) se sobrescribe -//! de forma independiente de estos tres pasos y no es necesario para tener un tema funcional. +//! de forma independiente de estos pasos y no es necesario para tener un tema funcional. //! //! # Componentes que se procesan en todas las páginas //! diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 70767cac..b8a83d01 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -3,7 +3,7 @@ use crate::base::component::{Html, Intro, IntroOpening, layout}; use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender}; use crate::core::component::{Context, Contextual}; use crate::core::extension::Extension; -use crate::core::theme::CoreRegions; +use crate::core::theme::{CoreRegions, Intent}; use crate::global; use crate::html::{Markup, html}; use crate::locale::Lc; @@ -65,6 +65,34 @@ pub trait Theme: Extension + Send + Sync { None } + /// Traduce una [`Intent`] al nombre de color de la paleta propia del tema. + /// + /// `Intent` no define ninguna cadena propia. Cada tema decide qué nombre le corresponde a cada + /// variante en su paleta (p. ej. un tema basado en Bootstrap traduce `Severe` a `"danger"`). + /// Los componentes que generan clases CSS a partir de una `Intent` (`Button`, `Badge`, + /// `Dropdown`, etc.) no llaman a este método directamente en su `setup()`, usan mejor + /// [`Intent::color()`](crate::core::theme::Intent::color) como la forma más sencilla de obtener + /// este mismo valor a través de [`Context::theme()`](crate::core::component::Context::theme), + /// para que la clase resultante ya nazca en la paleta del tema activo. + /// + /// La implementación por defecto devuelve el vocabulario semántico propio de PageTop + /// (`"primary"`, `"severe"`, etc.), que actúa como paleta base cuando ningún tema la + /// sobrescribe. + #[rustfmt::skip] + fn intent_color(&self, intent: Intent) -> &'static str { + if let Some(parent) = self.parent() { + return parent.intent_color(intent); + } + match intent { + Intent::Primary => "primary", + Intent::Neutral => "neutral", + Intent::Info => "info", + Intent::Success => "success", + Intent::Warning => "warning", + Intent::Severe => "severe", + } + } + /// Acciones específicas del tema antes de renderizar el `` de la página. /// /// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página, diff --git a/src/core/theme/intent.rs b/src/core/theme/intent.rs index ff6e4626..2fb03a94 100644 --- a/src/core/theme/intent.rs +++ b/src/core/theme/intent.rs @@ -1,31 +1,41 @@ use crate::AutoDefault; +use crate::core::component::{Context, Contextual}; /// 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. +/// Describe *qué comunica* un componente, y cada tema traduce la intención a su propia paleta de +/// colores. Define un vocabulario con variantes por énfasis (`Primary`, `Neutral`), un canal +/// informativo (`Info`) y una escala de severidad (`Success`, `Warning`, `Severe`). #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub enum Intent { + /// Énfasis principal, suele ser la acción que más destaca a la vista. #[default] Primary, - Secondary, - Success, + /// Presencia sin énfasis, una opción discreta o normal. + Neutral, + /// Informa de algo que merece leerse, ajeno a la escala de severidad. Encaja en notas o avisos + /// informativos. Info, + /// Destaca algo que ha ido bien, un estado favorable o un camino afirmativo. En una alerta + /// podría indicar la confirmación de una operación correcta o un estado saludable; en un botón, + /// la acción que confirma o aprueba algo positivamente. + Success, + /// Precaución, representa algo que requiere atención o cuidado. En una alerta, sería un + /// problema no bloqueante o un aviso; en un botón, una acción que conviene meditar sin llegar a + /// ser destructiva. Warning, - Danger, + /// Algo va mal, es peligroso o destructivo. En una alerta sería un error o un fallo bloqueante; + /// en un botón, la acción destructiva o irreversible (p. ej. eliminar). + Severe, } 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", - } + /// Devuelve el nombre del color asociado a la intención en el tema activo del contexto actual. + /// + /// Atajo de [`Theme::intent_color()`](crate::core::theme::Theme::intent_color) a través de + /// [`Context::theme()`]. La intención no tiene una cadena propia, es el tema activo quien + /// decide su traducción. + pub fn color(&self, cx: &Context) -> &'static str { + cx.theme().intent_color(*self) } } diff --git a/tests/component_badge.rs b/tests/component_badge.rs index 2d039e3b..f6a5e2d2 100644 --- a/tests/component_badge.rs +++ b/tests/component_badge.rs @@ -28,22 +28,22 @@ async fn renders_as_a_span_element() { } #[pagetop::test] -async fn default_intent_is_secondary() { +async fn default_intent_is_neutral() { let mut badge = Badge::labeled(Lc::n("Admin")); let html = badge.render(&mut Context::default()).await.into_string(); - assert!(html.contains(r#"class="badge badge-secondary""#)); + assert!(html.contains(r#"class="badge badge-neutral""#)); } #[pagetop::test] async fn each_direct_constructor_sets_its_intent() { let cases = [ (Badge::primary(Lc::n("x")), "badge-primary"), - (Badge::secondary(Lc::n("x")), "badge-secondary"), + (Badge::neutral(Lc::n("x")), "badge-neutral"), (Badge::success(Lc::n("x")), "badge-success"), (Badge::info(Lc::n("x")), "badge-info"), (Badge::warning(Lc::n("x")), "badge-warning"), - (Badge::danger(Lc::n("x")), "badge-danger"), + (Badge::severe(Lc::n("x")), "badge-severe"), ]; for (mut badge, expected_class) in cases { let html = badge.render(&mut Context::default()).await.into_string(); @@ -56,16 +56,16 @@ 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); + let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe); let html = badge.render(&mut Context::default()).await.into_string(); - assert!(html.contains(r#"class="badge badge-danger""#)); + assert!(html.contains(r#"class="badge badge-severe""#)); } #[pagetop::test] 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_intent(Intent::Danger); + let mut from_constructor = Badge::severe(Lc::n("Admin")); + let mut from_builder = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe); assert_eq!( from_constructor @@ -89,9 +89,9 @@ async fn with_id_sets_the_identifier() { #[pagetop::test] 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 mut badge = Badge::severe(Lc::n("Admin")).with_prop(PropsOp::add_classes("custom")); let html = badge.render(&mut Context::default()).await.into_string(); - assert!(html.contains("badge-danger")); + assert!(html.contains("badge-severe")); assert!(html.contains("custom")); }