diff --git a/extensions/pagetop-bootsier/src/theme/bs/container/component.rs b/extensions/pagetop-bootsier/src/theme/bs/container/component.rs index b0fba98e..ed9a5f7d 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/container/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/container/component.rs @@ -21,7 +21,7 @@ use crate::theme::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Container { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el tipo semántico del contenedor. container_kind: bs::container::Kind, @@ -141,7 +141,7 @@ impl Container { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. /// /// También acepta clases predefinidas para: /// diff --git a/extensions/pagetop-bootsier/src/theme/bs/dropdown/component.rs b/extensions/pagetop-bootsier/src/theme/bs/dropdown/component.rs index 942f425c..333b633e 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/dropdown/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/dropdown/component.rs @@ -39,7 +39,7 @@ use crate::theme::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Dropdown { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el título del menú desplegable. title: L10n, @@ -185,7 +185,7 @@ impl Dropdown { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/extensions/pagetop-bootsier/src/theme/bs/dropdown/item.rs b/extensions/pagetop-bootsier/src/theme/bs/dropdown/item.rs index df3947b7..29375fee 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/dropdown/item.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/dropdown/item.rs @@ -45,7 +45,7 @@ pub enum ItemKind { /// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú. #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Item { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el tipo de elemento representado. item_kind: ItemKind, @@ -266,7 +266,7 @@ impl Item { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/extensions/pagetop-bootsier/src/theme/bs/icon.rs b/extensions/pagetop-bootsier/src/theme/bs/icon.rs index 1b519eaf..8559d18a 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/icon.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/icon.rs @@ -15,7 +15,7 @@ pub enum IconKind { #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Icon { - /// Devuelve los atributos HTML y clases CSS del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, icon_kind: IconKind, aria_label: AttrL10n, @@ -110,7 +110,7 @@ impl Icon { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/extensions/pagetop-bootsier/src/theme/bs/image/component.rs b/extensions/pagetop-bootsier/src/theme/bs/image/component.rs index fb740ab1..041a1fed 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/image/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/image/component.rs @@ -14,7 +14,7 @@ use crate::theme::*; /// - Aplicar el texto alternativo `alt` con **localización** mediante [`L10n`]. #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Image { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve las dimensiones de la imagen. size: bs::image::Size, @@ -86,7 +86,7 @@ impl Image { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. /// /// También acepta clases predefinidas para: /// diff --git a/extensions/pagetop-bootsier/src/theme/bs/nav/component.rs b/extensions/pagetop-bootsier/src/theme/bs/nav/component.rs index c7f3df0f..3bd12472 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/nav/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/nav/component.rs @@ -34,7 +34,7 @@ use crate::theme::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Nav { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el estilo visual seleccionado. nav_kind: bs::nav::Kind, @@ -103,7 +103,7 @@ impl Nav { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/extensions/pagetop-bootsier/src/theme/bs/nav/item.rs b/extensions/pagetop-bootsier/src/theme/bs/nav/item.rs index 80078594..398d624e 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/nav/item.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/nav/item.rs @@ -59,7 +59,7 @@ impl ItemKind { /// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú. #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Item { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el tipo de elemento representado. item_kind: ItemKind, @@ -271,7 +271,7 @@ impl Item { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs index d6de4de1..aa678644 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs @@ -137,7 +137,7 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas"; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Navbar { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el punto de ruptura configurado. expand: token::BreakPoint, @@ -346,7 +346,7 @@ impl Navbar { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. /// /// También acepta clases predefinidas para: /// diff --git a/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs b/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs index d1931510..a0dbea65 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs @@ -43,7 +43,7 @@ use crate::theme::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Offcanvas { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el título del panel. title: L10n, @@ -100,7 +100,7 @@ impl Offcanvas { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/block.rs b/src/base/component/block.rs index 62c4c817..b3f48393 100644 --- a/src/base/component/block.rs +++ b/src/base/component/block.rs @@ -6,7 +6,7 @@ use crate::prelude::*; /// opcional y un cuerpo que sólo se renderiza si existen componentes hijos (*children*). #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Block { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el título del bloque. title: L10n, @@ -60,7 +60,7 @@ impl Block { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/button.rs b/src/base/component/button.rs index e5028ebb..f3aec897 100644 --- a/src/base/component/button.rs +++ b/src/base/component/button.rs @@ -65,7 +65,7 @@ impl fmt::Display for ButtonAction { /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Button { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el comportamiento del botón al activarse. kind: ButtonAction, @@ -158,7 +158,7 @@ impl Button { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/check.rs b/src/base/component/form/check.rs index 9d2438b1..d7718044 100644 --- a/src/base/component/form/check.rs +++ b/src/base/component/form/check.rs @@ -105,7 +105,7 @@ impl Item { /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Field { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el nombre base compartido por todas las casillas del grupo. name: AttrName, @@ -201,7 +201,7 @@ impl Field { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/checkbox.rs b/src/base/component/form/checkbox.rs index 568299d4..cb9323af 100644 --- a/src/base/component/form/checkbox.rs +++ b/src/base/component/form/checkbox.rs @@ -39,7 +39,7 @@ use crate::prelude::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Checkbox { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve la variante visual del control. checkbox_kind: form::CheckboxKind, @@ -158,7 +158,7 @@ impl Checkbox { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/component.rs b/src/base/component/form/component.rs index 7dfd72fe..715b5eaf 100644 --- a/src/base/component/form/component.rs +++ b/src/base/component/form/component.rs @@ -44,7 +44,7 @@ use crate::base::component::form; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Form { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve la URL/ruta de destino del formulario. action: AttrValue, @@ -99,7 +99,7 @@ impl Form { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/fieldset.rs b/src/base/component/form/fieldset.rs index 45a792fc..cc6373c7 100644 --- a/src/base/component/form/fieldset.rs +++ b/src/base/component/form/fieldset.rs @@ -24,7 +24,7 @@ use crate::prelude::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Fieldset { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve la leyenda del `fieldset`. legend: Attr, @@ -77,7 +77,7 @@ impl Fieldset { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/input.rs b/src/base/component/form/input.rs index 33ebb9c3..565c055c 100644 --- a/src/base/component/form/input.rs +++ b/src/base/component/form/input.rs @@ -123,7 +123,7 @@ impl fmt::Display for Mode { /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Field { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el tipo de campo. kind: Kind, @@ -302,7 +302,7 @@ impl Field { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/radio.rs b/src/base/component/form/radio.rs index b045f112..330b98f3 100644 --- a/src/base/component/form/radio.rs +++ b/src/base/component/form/radio.rs @@ -94,7 +94,7 @@ impl Item { /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Field { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el nombre compartido por todos los botones de opción del grupo. name: AttrName, @@ -204,7 +204,7 @@ impl Field { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/range.rs b/src/base/component/form/range.rs index 183e0a76..51e8b385 100644 --- a/src/base/component/form/range.rs +++ b/src/base/component/form/range.rs @@ -31,7 +31,7 @@ use crate::prelude::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Range { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el nombre del campo. name: AttrName, @@ -112,7 +112,7 @@ impl Range { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/select.rs b/src/base/component/form/select.rs index b9ebfc12..3796b42b 100644 --- a/src/base/component/form/select.rs +++ b/src/base/component/form/select.rs @@ -188,7 +188,7 @@ pub enum Entry { /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Field { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el nombre del campo. name: AttrName, @@ -312,7 +312,7 @@ impl Field { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/base/component/form/textarea.rs b/src/base/component/form/textarea.rs index 3b2b5fb5..8cafccd5 100644 --- a/src/base/component/form/textarea.rs +++ b/src/base/component/form/textarea.rs @@ -33,7 +33,7 @@ use crate::prelude::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Textarea { - /// Devuelve identificador, clases CSS y atributos HTML del componente. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, /// Devuelve el nombre del campo. name: AttrName, @@ -142,7 +142,7 @@ impl Textarea { self } - /// Modifica identificador, clases CSS o atributos HTML del componente. + /// 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); diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 33e009d6..48b774dd 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -144,7 +144,7 @@ pub trait Contextual: LangId { #[builder_fn] fn with_assets(self, op: AssetsOp) -> Self; - /// Modifica identificador, clases CSS o atributos HTML del elemento ``. + /// Modifica identificador, clases CSS, atributos HTML o valores extra del elemento ``. #[builder_fn] fn with_body_props(self, op: PropsOp) -> Self; @@ -237,7 +237,7 @@ pub trait Contextual: LangId { /// Devuelve los scripts JavaScript de los recursos del contexto. fn javascripts(&self) -> &Assets; - /// Devuelve identificador, clases CSS y atributos HTML del elemento ``. + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del elemento ``. fn body_props(&self) -> &Props; // **< Contextual HELPERS >********************************************************************* diff --git a/src/html.rs b/src/html.rs index f8a5dfc4..32068cde 100644 --- a/src/html.rs +++ b/src/html.rs @@ -24,7 +24,7 @@ mod attr; pub use attr::{Attr, AttrName, AttrValue}; mod props; -pub use props::{Props, PropsOp}; +pub use props::{Props, PropsError, PropsExtra, PropsOp}; mod unit; pub use unit::UnitValue; diff --git a/src/html/props.rs b/src/html/props.rs index b11347b6..e0e87543 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -1,50 +1,133 @@ +use crate::core::TypeInfo; use crate::html::maud::{Escaper, Render}; use crate::{AutoDefault, CowStr, builder_fn, util}; -use std::fmt::Write; +use std::any::Any; +use std::collections::HashMap; +use std::fmt::{self, Write}; +use std::sync::Arc; + +// **< PropsExtra >********************************************************************************* + +/// Encapsula un valor tipado extra para almacenar en [`Props`]. +/// +/// Internamente usa [`Arc`] para que [`Props`] pueda implementar [`Clone`] sin requerir que los +/// valores almacenados sean clonables. El nombre del tipo almacenado permite generar mensajes de +/// error precisos. +pub struct PropsExtra { + value: Arc, + type_name: &'static str, +} + +impl Clone for PropsExtra { + fn clone(&self) -> Self { + Self { + value: Arc::clone(&self.value), + type_name: self.type_name, + } + } +} + +impl fmt::Debug for PropsExtra { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "<{}>", self.type_name) + } +} + +// **< PropsError >********************************************************************************* + +/// Errores de acceso a valores extra de [`Props`]. +/// +/// - [`PropsError::ExtraNotFound`]: la clave no existe. Incluye la clave (`key`). +/// - [`PropsError::ExtraTypeMismatch`]: la clave existe pero el tipo solicitado no coincide con el +/// almacenado. Incluye la clave (`key`), tipo esperado (`expected`) y tipo realmente encontrado +/// (`found`) para facilitar el diagnóstico. +#[derive(Debug, PartialEq, Eq)] +pub enum PropsError { + ExtraNotFound { + key: &'static str, + }, + ExtraTypeMismatch { + key: &'static str, + expected: &'static str, + found: &'static str, + }, +} + +impl fmt::Display for PropsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PropsError::ExtraNotFound { key } => write!(f, "extra \"{key}\" not found"), + PropsError::ExtraTypeMismatch { + key, + expected, + found, + } => write!( + f, + "type mismatch for extra \"{key}\": expected \"{expected}\", found \"{found}\"" + ), + } + } +} + +impl std::error::Error for PropsError {} // **< PropsOp >************************************************************************************ -/// Operaciones disponibles sobre atributos HTML y clases CSS en [`Props`]. +/// Operaciones sobre el identificador, clases CSS, atributos HTML y valores extra en [`Props`]. /// /// Cada variante lleva los datos necesarios para ejecutarse. El método recomendado para usarlas es -/// recurrir a los constructores asociados como [`set()`](Self::set), [`set_id()`](Self::set_id), -/// [`remove()`](Self::remove), [`add_classes()`](Self::add_classes), etc. +/// recurrir a los constructores asociados como [`set_id()`](Self::set_id), +/// [`add_classes()`](Self::add_classes), [`set()`](Self::set), etc. /// /// Las variantes `*Id` operan sobre el atributo `id` del componente. Cuando se usa `"id"` como -/// nombre de atributo en `Set`, el valor se normaliza igual que en `SetId` o `EnsureId`. +/// nombre de atributo en `Set`, el valor se normaliza igual que [`SetId`](Self::SetId). /// -/// Las variantes `*Classes` operan siempre sobre la lista de clases CSS para el componente. Cuando -/// se usa `"class"` como nombre en `Set` o `Remove` la operación se aplica a la lista de clases -/// completa. Así, `Set("class", ...)` reemplaza la lista de clases completa por las nuevas clases -/// indicadas, y `Remove("class")` vacía la lista de clases. -#[derive(Clone, Debug, PartialEq)] +/// Las variantes `*Classes` gestionan la lista de clases CSS. Además, `Set("class", ...)` +/// reemplaza la lista completa y `Remove("class")` la vacía. +/// +/// Las variantes [`Set`](Self::Set) y [`Remove`](Self::Remove) son operaciones de propósito +/// general: `Set` añade o reemplaza cualquier atributo HTML por nombre y valor, y `Remove` lo +/// elimina. Los atributos `id` y `class` tienen semántica especial documentada en cada variante. +/// +/// Las variantes `*Extra` permiten añadir valores tipados usando una clave. Están pensadas para que +/// temas y extensiones amplíen el comportamiento de componentes ya existentes. Como no es posible +/// añadir campos a la estructura de un componente ya definido, temas y extensiones pueden definir +/// un *trait* con nuevos métodos que leen y escriben valores extra en [`Props`]. Esos valores se +/// interpretan como si fueran valores internos del componente para tomar decisiones durante el +/// renderizado. +#[derive(Clone, Debug)] pub enum PropsOp { - /// Establece el identificador del elemento normalizando el valor: recorta espacios, convierte a - /// minúsculas y sustituye los espacios intermedios por `_`. Si el resultado es vacío, elimina + /// Establece el identificador del componente normalizando el valor: recorta espacios, convierte + /// a minúsculas y sustituye los espacios intermedios por `_`. Si el resultado es vacío, elimina /// el identificador. SetId(CowStr), - /// Establece el identificador del elemento si aún no hay ninguno definido, de modo que no - /// sobrescribe un valor asignado con anterioridad. Aplica la misma normalización que - /// [`SetId`](Self::SetId); si el resultado es vacío, la operación tampoco tiene efecto. + /// Establece el identificador del componente **sólo si aún no hay ninguno definido**. Aplica la + /// misma normalización que [`SetId`](Self::SetId); si el resultado es vacío, la operación no + /// tiene efecto. EnsureId(CowStr), - /// Añade el atributo o sustituye su valor si ya existe. Usar `"id"` como nombre aplica la misma - /// normalización que [`SetId`](Self::SetId). Usar `"class"` como nombre reemplaza la lista - /// completa de clases por las nuevas indicadas; la operación se ignora si el valor contiene - /// caracteres no ASCII. - Set(CowStr, CowStr), - /// Elimina el atributo indicado, incluido `"id"`. Si se usa `"class"` como nombre se vacía la - /// lista de clases. - Remove(CowStr), - /// Añade las clases que no existan al final de la lista. La operación se ignora si el valor - /// contiene caracteres no ASCII. + /// Añade la clase o clases que no existan al final de la lista. La operación se ignora si el + /// valor contiene caracteres no ASCII. AddClasses(CowStr), - /// Añade las clases que no existan al principio de la lista. La operación se ignora si el valor - /// contiene caracteres no ASCII. + /// Añade la clase o clases que no existan al principio de la lista. La operación se ignora si + /// el valor contiene caracteres no ASCII. PrependClasses(CowStr), - /// Elimina las clases indicadas de la lista. La operación se ignora si el valor contiene + /// Elimina la clase o clases indicadas de la lista. La operación se ignora si el valor contiene /// caracteres no ASCII. RemoveClasses(CowStr), + /// Añade un atributo o sustituye su valor si ya existe. Usar `"id"` como nombre de atributo + /// aplica al valor la misma normalización que [`SetId`](Self::SetId). Usar `"class"` como + /// nombre de atributo reemplaza la lista completa de clases por las nuevas indicadas; la + /// operación se ignora si el valor contiene caracteres no ASCII. + Set(CowStr, CowStr), + /// Elimina el atributo indicado. Usar `"id"` elimina el identificador; usar `"class"` vacía la + /// lista de clases. + Remove(CowStr), + /// Almacena un valor extra tipado asociado a la clave indicada. Si ya existe uno con esa clave, + /// lo reemplaza. + SetExtra(&'static str, PropsExtra), + /// Elimina el valor extra asociado a la clave indicada, si existe. + RemoveExtra(&'static str), } impl PropsOp { @@ -58,6 +141,21 @@ impl PropsOp { Self::EnsureId(id.into()) } + /// Crea la variante [`AddClasses`](Self::AddClasses) con la clase o clases indicadas. + pub fn add_classes(classes: impl Into) -> Self { + Self::AddClasses(classes.into()) + } + + /// Crea la variante [`PrependClasses`](Self::PrependClasses) con la clase o clases indicadas. + pub fn prepend_classes(classes: impl Into) -> Self { + Self::PrependClasses(classes.into()) + } + + /// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con la clase o clases indicadas. + pub fn remove_classes(classes: impl Into) -> Self { + Self::RemoveClasses(classes.into()) + } + /// Crea la variante [`Set`](Self::Set) con nombre y valor del atributo. pub fn set(name: impl Into, value: impl Into) -> Self { Self::Set(name.into(), value.into()) @@ -68,28 +166,37 @@ impl PropsOp { Self::Remove(name.into()) } - /// Crea la variante [`AddClasses`](Self::AddClasses) con las clases indicadas. - pub fn add_classes(classes: impl Into) -> Self { - Self::AddClasses(classes.into()) + /// Crea la variante [`SetExtra`](Self::SetExtra) con la clave y el valor indicados. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_SIZE: &str = "myext.size"; + /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_SIZE, 42_u32)); + /// assert_eq!(props.extra_or(EXT_SIZE, 0_u32), 42); + /// ``` + pub fn set_extra(key: &'static str, value: T) -> Self { + Self::SetExtra( + key, + PropsExtra { + value: Arc::new(value), + type_name: TypeInfo::FullName.of::(), + }, + ) } - /// Crea la variante [`PrependClasses`](Self::PrependClasses) con las clases indicadas. - pub fn prepend_classes(classes: impl Into) -> Self { - Self::PrependClasses(classes.into()) - } - - /// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con las clases indicadas. - pub fn remove_classes(classes: impl Into) -> Self { - Self::RemoveClasses(classes.into()) + /// Crea la variante [`RemoveExtra`](Self::RemoveExtra) para la clave indicada. + pub fn remove_extra(key: &'static str) -> Self { + Self::RemoveExtra(key) } } // **< Props >************************************************************************************** -/// Colección de identificador, atributos HTML y clases CSS para aplicar en componentes. +/// Recoge el identificador, clases CSS, atributos HTML y valores extra de un componente. /// -/// Al renderizar en `html!` emite primero `id` (si existe), luego `class` (si hay clases) y después -/// el resto de atributos. +/// Al renderizar con [`html!`](crate::html::html) se emite primero el identificador `id` (si +/// existe), luego `class` (si hay clases) y después el resto de atributos, normalmente aplicados al +/// elemento raíz del componente. /// /// # Ejemplo /// @@ -111,7 +218,7 @@ impl PropsOp { /// /// # Identificadores /// -/// [`SetId`](PropsOp::SetId) (usando [`PropsOp::set_id`]) normaliza el valor asignado al +/// [`SetId`](PropsOp::SetId) (usando [`PropsOp::set_id()`]) normaliza el valor asignado al /// identificador del componente: recorta espacios, convierte a minúsculas y sustituye los espacios /// intermedios por `_`. /// @@ -122,8 +229,8 @@ impl PropsOp { /// assert_eq!(markup.into_string(), r#""#); /// ``` /// -/// [`EnsureId`](PropsOp::EnsureId) (usando [`PropsOp::ensure_id`]) sólo asigna si no -/// hay identificador previo: +/// [`EnsureId`](PropsOp::EnsureId) (usando [`PropsOp::ensure_id()`]) sólo asigna si no hay +/// identificador previo: /// /// ```rust /// # use pagetop::prelude::*; @@ -150,6 +257,33 @@ impl PropsOp { /// assert_eq!(markup.into_string(), r#""#); /// ``` /// +/// # Valores extra +/// +/// Las variantes [`SetExtra`](PropsOp::SetExtra) y [`RemoveExtra`](PropsOp::RemoveExtra), usando +/// [`PropsOp::set_extra()`] y [`PropsOp::remove_extra()`] respectivamente, permiten adjuntar +/// valores tipados a un `Props`. Son útiles para que temas y extensiones amplíen el comportamiento +/// de componentes ya existentes mediante *traits* con nuevos métodos que lean y escriban esos +/// valores. +/// +/// ```rust +/// # use pagetop::prelude::*; +/// const EXT_ENABLED: &str = "myext.enabled"; +/// const EXT_LABEL: &str = "myext.label"; +/// +/// let props = Props::default() +/// .with_prop(PropsOp::set_extra(EXT_ENABLED, true)) +/// .with_prop(PropsOp::set_extra(EXT_LABEL, "flotante".to_string())); +/// +/// assert!(props.extra_or(EXT_ENABLED, false)); +/// assert_eq!(props.extra_or(EXT_LABEL, String::new()), "flotante"); +/// +/// // Tipo incorrecto devuelve el valor por defecto indicado: +/// assert_eq!(props.extra_or(EXT_ENABLED, 0_u8), 0); +/// ``` +/// +/// Los valores extra no se emiten en el HTML al renderizar; son exclusivamente para uso interno de +/// temas y extensiones. +/// /// # Integración en componentes /// /// El patrón recomendado es añadir un campo `props: Props` con su método *builder* delegado: @@ -176,7 +310,7 @@ impl PropsOp { /// } /// /// impl MyButton { -/// /// Modifica identificador, clases CSS o atributos HTML del elemento raíz. +/// /// 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); @@ -187,8 +321,9 @@ impl PropsOp { #[derive(AutoDefault, Clone, Debug)] pub struct Props { id: Option, - attrs: Vec<(CowStr, CowStr)>, classes: Vec, + attrs: Vec<(CowStr, CowStr)>, + extras: HashMap<&'static str, PropsExtra>, } impl Props { @@ -211,20 +346,21 @@ impl Props { self } - /// Modifica el identificador, los atributos o las clases según la operación indicada. + /// Modifica el identificador, las clases, los atributos o los valores extra según la operación + /// indicada. El método recomendado para construir cada operación es usar los constructores de + /// [`PropsOp`]: /// - /// - [`SetId(value)`](PropsOp::SetId) establece el identificador normalizando el valor. - /// - [`EnsureId(value)`](PropsOp::EnsureId) establece el identificador (con la misma - /// normalización) sólo si no hay ninguno definido. - /// - [`Set(name, value)`](PropsOp::Set) añade el atributo o reemplaza su valor. - /// `Set("id", ...)` aplica la misma normalización que `SetId`. - /// `Set("class", ...)` reemplaza la lista de clases completa. - /// - [`Remove(name)`](PropsOp::Remove) elimina el atributo. `Remove("id")` elimina el - /// identificador. `Remove("class")` vacía la lista de clases. - /// - [`AddClasses(clases)`](PropsOp::AddClasses) añade clases al final (sin duplicados). - /// - [`PrependClasses(clases)`](PropsOp::PrependClasses) añade clases al principio (sin - /// duplicados). - /// - [`RemoveClasses(clases)`](PropsOp::RemoveClasses) elimina las clases indicadas. + /// - [`PropsOp::set_id()`] - establece el identificador normalizando el valor. + /// - [`PropsOp::ensure_id()`] - establece el identificador sólo si no hay ninguno definido. + /// - [`PropsOp::add_classes()`] - añade clases al final (sin duplicados). + /// - [`PropsOp::prepend_classes()`] - añade clases al principio (sin duplicados). + /// - [`PropsOp::remove_classes()`] - elimina las clases indicadas. + /// - [`PropsOp::set()`] - añade el atributo o reemplaza su valor. `set("id", ...)` aplica la + /// misma normalización que `set_id()`. `set("class", ...)` reemplaza la lista de clases. + /// - [`PropsOp::remove()`] - elimina el atributo. `remove("id")` elimina el identificador; + /// `remove("class")` vacía la lista de clases. + /// - [`PropsOp::set_extra()`] - almacena un valor extra tipado. + /// - [`PropsOp::remove_extra()`] - elimina el valor extra asociado a la clave. #[builder_fn] pub fn with_prop(mut self, op: PropsOp) -> Self { match op { @@ -236,31 +372,6 @@ impl Props { self.apply_id(value.as_ref()); } } - PropsOp::Set(name, value) => { - if name.as_ref() == "id" { - self.apply_id(value.as_ref()); - } else if name.as_ref() == "class" { - if let Some(normalized) = - util::normalize_ascii_or_empty(value.as_ref(), "Props::with_prop") - { - self.classes.clear(); - self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); - } - } else if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &name) { - self.attrs[pos].1 = value; - } else { - self.attrs.push((name, value)); - } - } - PropsOp::Remove(name) => { - if name.as_ref() == "id" { - self.id = None; - } else if name.as_ref() == "class" { - self.classes.clear(); - } else { - self.attrs.retain(|(k, _)| k != &name); - } - } PropsOp::AddClasses(classes) => { let Some(normalized) = util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") @@ -291,18 +402,58 @@ impl Props { .any(|r| r == c.as_str()) }); } + PropsOp::Set(name, value) => { + if name.as_ref() == "id" { + self.apply_id(value.as_ref()); + } else if name.as_ref() == "class" { + if let Some(normalized) = + util::normalize_ascii_or_empty(value.as_ref(), "Props::with_prop") + { + self.classes.clear(); + self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); + } + } else if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &name) { + self.attrs[pos].1 = value; + } else { + self.attrs.push((name, value)); + } + } + PropsOp::Remove(name) => { + if name.as_ref() == "id" { + self.id = None; + } else if name.as_ref() == "class" { + self.classes.clear(); + } else { + self.attrs.retain(|(k, _)| k != &name); + } + } + PropsOp::SetExtra(key, extra) => { + self.extras.insert(key, extra); + } + PropsOp::RemoveExtra(key) => { + self.extras.remove(key); + } } self } // **< Props GETTERS >************************************************************************** - /// Devuelve el identificador normalizado del elemento, si existe. + /// Devuelve el identificador normalizado del componente, si existe. #[inline] pub fn get_id(&self) -> Option { self.id.clone() } + /// Devuelve la lista de clases como cadena de texto, si hay clases definidas. + pub fn get_classes(&self) -> Option { + if self.classes.is_empty() { + None + } else { + Some(self.classes.join(" ")) + } + } + /// Devuelve el valor del atributo indicado, si existe. /// /// Los nombres `"id"` y `"class"` son equivalentes a llamar a [`get_id()`](Self::get_id) y @@ -319,35 +470,27 @@ impl Props { } } - /// Devuelve la lista de clases como cadena de texto, si hay clases definidas. - pub fn get_classes(&self) -> Option { - if self.classes.is_empty() { - None - } else { - Some(self.classes.join(" ")) - } - } - /// Devuelve `true` si no hay ningún identificador definido. #[inline] pub fn is_id_empty(&self) -> bool { self.id.is_none() } - /// Devuelve `true` si no hay ningún atributo extra definido, sin tener en cuenta el - /// identificador ni las clases. - #[inline] - pub fn is_attrs_empty(&self) -> bool { - self.attrs.is_empty() - } - /// Devuelve `true` si no hay ninguna clase definida. #[inline] pub fn is_classes_empty(&self) -> bool { self.classes.is_empty() } - /// Devuelve `true` si no hay ningún identificador, atributo ni clase definidos. + /// Devuelve `true` si no hay ningún atributo adicional definido, sin tener en cuenta el + /// identificador ni las clases. + #[inline] + pub fn is_attrs_empty(&self) -> bool { + self.attrs.is_empty() + } + + /// Devuelve `true` si no hay ningún identificador, clases ni atributos adicionales definidos, + /// sin tener en cuenta los valores extra. #[inline] pub fn is_empty(&self) -> bool { self.id.is_none() && self.attrs.is_empty() && self.classes.is_empty() @@ -375,6 +518,96 @@ impl Props { .any(|class| self.classes.iter().any(|c| c == class)) } + /// Recupera una referencia tipada al valor extra asociado a la clave `key`. + /// + /// Devuelve un [`Result`] que indica si la clave existe y si el tipo coincide: + /// + /// - `Ok(&T)` si la clave existe y el tipo coincide. El tipo `T` debe ser el mismo que se usó + /// al almacenar el valor con [`PropsOp::set_extra()`]. + /// - `Err(PropsError::ExtraNotFound)` si la clave no existe. + /// - `Err(PropsError::ExtraTypeMismatch)` si el tipo no coincide. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_COUNT: &str = "myext.count"; + /// const EXT_OTHER: &str = "myext.other"; + /// + /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_COUNT, 7_i32)); + /// + /// assert_eq!(*props.extra::(EXT_COUNT).unwrap(), 7); + /// assert_eq!(props.extra::(EXT_OTHER), Err(PropsError::ExtraNotFound { key: EXT_OTHER })); + /// assert!(matches!( + /// props.extra::(EXT_COUNT), + /// Err(PropsError::ExtraTypeMismatch { .. }) + /// )); + /// ``` + pub fn extra(&self, key: &'static str) -> Result<&T, PropsError> { + let ev = self + .extras + .get(key) + .ok_or(PropsError::ExtraNotFound { key })?; + ev.value + .downcast_ref::() + .ok_or_else(|| PropsError::ExtraTypeMismatch { + key, + expected: TypeInfo::FullName.of::(), + found: ev.type_name, + }) + } + + /// Devuelve el valor extra clonado o el **valor `default`** si no existe o el tipo no coincide. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_FLAG: &str = "myext.flag"; + /// const EXT_OTHER: &str = "myext.other"; + /// + /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_FLAG, true)); + /// + /// assert!(props.extra_or(EXT_FLAG, false)); + /// assert!(!props.extra_or(EXT_OTHER, false)); + /// ``` + pub fn extra_or(&self, key: &'static str, default: T) -> T { + self.extra::(key).ok().cloned().unwrap_or(default) + } + + /// Devuelve el valor extra clonado o el **valor por defecto del tipo** si no existe o el tipo + /// no coincide. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_FLAG: &str = "myext.flag"; + /// const EXT_COUNT: &str = "myext.count"; + /// + /// let props = Props::default(); + /// + /// assert_eq!(props.extra_or_default::(EXT_FLAG), false); + /// assert_eq!(props.extra_or_default::(EXT_COUNT), 0); + /// ``` + pub fn extra_or_default(&self, key: &'static str) -> T { + self.extra::(key).ok().cloned().unwrap_or_default() + } + + /// Devuelve el valor extra clonado o el **valor evaluado por la función `f`** si no existe o el + /// tipo no coincide. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_LABEL: &str = "myext.label"; + /// + /// let props = Props::default(); + /// + /// let result = props.extra_or_else(EXT_LABEL, || "default".to_string()); + /// assert_eq!(result, "default"); + /// ``` + pub fn extra_or_else T>( + &self, + key: &'static str, + f: F, + ) -> T { + self.extra::(key).ok().cloned().unwrap_or_else(f) + } + // **< Props PRIVATE >************************************************************************** fn apply_id(&mut self, id: &str) { diff --git a/tests/html_props.rs b/tests/html_props.rs index 3db26746..d6a8a7e4 100644 --- a/tests/html_props.rs +++ b/tests/html_props.rs @@ -23,35 +23,6 @@ async fn props_get_missing_key_returns_none() { assert_eq!(p.get_prop(""), None); } -// **< Props::classes >***************************************************************************** - -#[pagetop::test] -async fn props_classes_renders_class_attribute() { - let p = Props::classes("btn btn-primary"); - assert_eq!( - html! { button (p) { "OK" } }.into_string(), - r#""# - ); -} - -#[pagetop::test] -async fn props_classes_empty_input_renders_no_class_attribute() { - let p = Props::classes(" "); - assert_eq!( - html! { button (p) { "OK" } }.into_string(), - "" - ); -} - -#[pagetop::test] -async fn props_classes_can_be_extended_with_with_prop() { - let p = Props::classes("btn").with_prop(PropsOp::add_classes("active")); - assert_eq!( - html! { button (p) { "OK" } }.into_string(), - r#""# - ); -} - // **< PropsOp::set >******************************************************************************* #[pagetop::test] @@ -222,7 +193,7 @@ async fn props_splice_empty_string_emits_nothing() { assert_eq!(html! { span ("") { "x" } }.into_string(), "x"); } -// **< is_attrs_empty / is_classes_empty / is_empty >*********************************************** +// **< is_attrs_empty / is_empty >****************************************************************** #[pagetop::test] async fn props_is_attrs_empty_on_default() { @@ -260,23 +231,7 @@ async fn props_is_empty_false_with_class() { assert!(!Props::classes("btn").is_empty()); } -#[pagetop::test] -async fn props_is_classes_empty_on_default() { - assert!(Props::default().is_classes_empty()); -} - -#[pagetop::test] -async fn props_is_classes_empty_false_after_add_classes() { - assert!(!Props::classes("btn").is_classes_empty()); -} - -#[pagetop::test] -async fn props_is_classes_empty_true_after_remove_class() { - let p = Props::classes("btn").with_prop(PropsOp::remove("class")); - assert!(p.is_classes_empty()); -} - -// **< get_prop("id") / get_prop("class") >********************************************************* +// **< get_prop("id") >***************************************************************************** #[pagetop::test] async fn get_prop_id_returns_none_by_default() { @@ -295,26 +250,6 @@ async fn get_prop_id_matches_get_id() { assert_eq!(p.get_prop("id"), p.get_id()); } -#[pagetop::test] -async fn get_prop_class_returns_none_by_default() { - assert_eq!(Props::default().get_prop("class"), None); -} - -#[pagetop::test] -async fn get_prop_class_returns_joined_classes() { - let p = Props::classes("btn btn-primary").with_prop(PropsOp::add_classes("active")); - assert_eq!( - p.get_prop("class"), - Some("btn btn-primary active".to_string()) - ); -} - -#[pagetop::test] -async fn get_prop_class_matches_get_classes() { - let p = Props::classes("btn active"); - assert_eq!(p.get_prop("class"), p.get_classes()); -} - // **< Regression & edge cases >******************************************************************** #[pagetop::test] diff --git a/tests/html_classes.rs b/tests/html_props_classes.rs similarity index 67% rename from tests/html_classes.rs rename to tests/html_props_classes.rs index 6beeca44..68583bff 100644 --- a/tests/html_classes.rs +++ b/tests/html_props_classes.rs @@ -11,7 +11,7 @@ fn assert_classes(p: &Props, expected: Option<&str>) { ); } -// **< Construction & invariants (new/get) >******************************************************** +// **< Props::classes (construction) >************************************************************** #[pagetop::test] async fn classes_new_empty_and_whitespace_is_empty() { @@ -28,13 +28,7 @@ async fn classes_new_normalizes_and_dedups_and_preserves_first_occurrence_order( assert!(p.has_class("btn-primary")); } -#[pagetop::test] -async fn classes_get_returns_none_when_empty_some_when_not() { - assert_classes(&Props::classes(" "), None); - assert_classes(&Props::classes("a"), Some("a")); -} - -// **< Basic operations (add/prepend/set) >********************************************************* +// **< PropsOp::add_classes >*********************************************************************** #[pagetop::test] async fn classes_add_appends_unique_and_normalizes() { @@ -49,7 +43,7 @@ async fn classes_add_ignores_empty_input() { } #[pagetop::test] -async fn classes_add_same_tokens() { +async fn classes_add_same_tokens_is_noop() { let p = Props::classes("a b").with_prop(PropsOp::add_classes("A B a b")); assert_classes(&p, Some("a b")); } @@ -60,6 +54,8 @@ async fn classes_add_rejects_non_ascii_is_noop() { assert_classes(&p, Some("a b")); } +// **< PropsOp::prepend_classes >******************************************************************* + #[pagetop::test] async fn classes_prepend_inserts_at_front_preserving_new_order() { let p = Props::classes("c d").with_prop(PropsOp::prepend_classes("A b")); @@ -78,6 +74,8 @@ async fn classes_prepend_ignores_empty_input() { assert_classes(&p, Some("a b")); } +// **< PropsOp::set / remove ("class") >************************************************************ + #[pagetop::test] async fn classes_reset_replaces_entire_list_and_dedups() { let p = Props::classes("a b c").with_prop(PropsOp::set("class", "X y y Z")); @@ -96,7 +94,13 @@ async fn classes_reset_with_non_ascii_is_noop() { assert_classes(&p, Some("a b")); } -// **< Mutation operations (remove) >*************************************************************** +#[pagetop::test] +async fn classes_remove_attr_clears_all_classes() { + let p = Props::classes("btn primary").with_prop(PropsOp::remove("class")); + assert_classes(&p, None); +} + +// **< PropsOp::remove_classes >******************************************************************** #[pagetop::test] async fn classes_remove_is_case_insensitive() { @@ -116,7 +120,7 @@ async fn classes_remove_with_extra_whitespace() { assert_classes(&p, Some("a c")); } -// **< Queries (contains) >************************************************************************* +// **< has_class / has_any_class >****************************************************************** #[pagetop::test] async fn classes_contains_single() { @@ -129,11 +133,9 @@ async fn classes_contains_single() { #[pagetop::test] async fn classes_contains_all_and_any() { let p = Props::classes("btn btn-primary active"); - assert!(p.has_class("btn active")); assert!(p.has_class("BTN BTN-PRIMARY")); assert!(!p.has_class("btn missing")); - assert!(p.has_any_class("missing active")); assert!(p.has_any_class("BTN-PRIMARY missing")); assert!(!p.has_any_class("missing other")); @@ -155,7 +157,68 @@ async fn classes_contains_non_ascii_is_false() { assert!(!p.has_any_class("a ñ")); } -// **< Properties / regression (combined sequences, ordering) >************************************* +// **< is_classes_empty >*************************************************************************** + +#[pagetop::test] +async fn props_is_classes_empty_on_default() { + assert!(Props::default().is_classes_empty()); +} + +#[pagetop::test] +async fn props_is_classes_empty_false_after_add_classes() { + assert!(!Props::classes("btn").is_classes_empty()); +} + +#[pagetop::test] +async fn props_is_classes_empty_true_after_remove_class() { + let p = Props::classes("btn").with_prop(PropsOp::remove("class")); + assert!(p.is_classes_empty()); +} + +// **< get_classes / get_prop("class") >************************************************************ + +#[pagetop::test] +async fn classes_get_returns_none_when_empty_some_when_not() { + assert_classes(&Props::classes(" "), None); + assert_classes(&Props::classes("a"), Some("a")); +} + +#[pagetop::test] +async fn get_prop_class_returns_joined_classes() { + let p = Props::classes("btn btn-primary").with_prop(PropsOp::add_classes("active")); + assert_eq!( + p.get_prop("class"), + Some("btn btn-primary active".to_string()) + ); +} + +#[pagetop::test] +async fn get_prop_class_matches_get_classes() { + let p = Props::classes("btn active"); + assert_eq!(p.get_prop("class"), p.get_classes()); +} + +// **< HTML rendering >***************************************************************************** + +#[pagetop::test] +async fn props_classes_renders_class_attribute() { + let p = Props::classes("btn btn-primary"); + assert_eq!( + html! { button (p) { "OK" } }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_classes_can_be_extended_with_add_classes() { + let p = Props::classes("btn").with_prop(PropsOp::add_classes("active")); + assert_eq!( + html! { button (p) { "OK" } }.into_string(), + r#""# + ); +} + +// **< Combined sequences >************************************************************************* #[pagetop::test] async fn classes_order_is_stable_for_existing_items() { diff --git a/tests/html_props_extras.rs b/tests/html_props_extras.rs new file mode 100644 index 00000000..dd8ffad5 --- /dev/null +++ b/tests/html_props_extras.rs @@ -0,0 +1,120 @@ +use pagetop::prelude::*; + +// **< PropsOp::set_extra / remove_extra >********************************************************** + +#[pagetop::test] +async fn set_and_read_extra() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + assert_eq!(*props.extra::("ext.flag").unwrap(), true); +} + +#[pagetop::test] +async fn overwrite_extra() { + let props = Props::default() + .with_prop(PropsOp::set_extra("ext.count", 1_u32)) + .with_prop(PropsOp::set_extra("ext.count", 2_u32)); + assert_eq!(*props.extra::("ext.count").unwrap(), 2); +} + +#[pagetop::test] +async fn remove_extra() { + let props = Props::default() + .with_prop(PropsOp::set_extra("ext.flag", true)) + .with_prop(PropsOp::remove_extra("ext.flag")); + assert_eq!( + props.extra::("ext.flag"), + Err(PropsError::ExtraNotFound { key: "ext.flag" }) + ); +} + +// **< Props::extra >******************************************************************************* + +#[pagetop::test] +async fn extra_not_found() { + let props = Props::default(); + assert_eq!( + props.extra::("ext.missing"), + Err(PropsError::ExtraNotFound { key: "ext.missing" }) + ); +} + +#[pagetop::test] +async fn extra_type_mismatch() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + assert!(matches!( + props.extra::("ext.flag"), + Err(PropsError::ExtraTypeMismatch { .. }) + )); +} + +// **< Props::extra_or >**************************************************************************** + +#[pagetop::test] +async fn extra_or_returns_value_when_found() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + assert_eq!(props.extra_or("ext.flag", false), true); +} + +#[pagetop::test] +async fn extra_or_returns_default_on_missing() { + let props = Props::default(); + assert_eq!(props.extra_or("ext.flag", false), false); +} + +#[pagetop::test] +async fn extra_or_returns_default_on_type_mismatch() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + assert_eq!(props.extra_or("ext.flag", 0_u32), 0_u32); +} + +// **< Props::extra_or_default >******************************************************************** + +#[pagetop::test] +async fn extra_or_default_returns_type_default_on_missing() { + let props = Props::default(); + assert_eq!(props.extra_or_default::("ext.flag"), false); + assert_eq!(props.extra_or_default::("ext.count"), 0); +} + +// **< Props::extra_or_else >*********************************************************************** + +#[pagetop::test] +async fn extra_or_else_calls_closure_on_missing() { + let props = Props::default(); + assert_eq!( + props.extra_or_else("ext.label", || "fallback".to_string()), + "fallback" + ); +} + +// **< Clone and mixed types >********************************************************************** + +#[pagetop::test] +async fn clone_preserves_extras() { + let props = Props::default().with_prop(PropsOp::set_extra("ext.flag", true)); + let cloned = props.clone(); + assert_eq!(*cloned.extra::("ext.flag").unwrap(), true); +} + +#[pagetop::test] +async fn multiple_extras_with_different_types() { + let props = Props::default() + .with_prop(PropsOp::set_extra("ext.flag", true)) + .with_prop(PropsOp::set_extra("ext.count", 42_u32)) + .with_prop(PropsOp::set_extra("ext.label", "hello".to_string())); + + assert_eq!(*props.extra::("ext.flag").unwrap(), true); + assert_eq!(*props.extra::("ext.count").unwrap(), 42); + assert_eq!(props.extra::("ext.label").unwrap(), "hello"); +} + +#[pagetop::test] +async fn extras_not_emitted_in_html() { + let props = Props::default() + .with_prop(PropsOp::set_extra("ext.flag", true)) + .with_prop(PropsOp::add_classes("btn")); + assert_eq!( + html! { button (props) { "OK" } }.into_string(), + r#""# + ); +}