♻️ (html): Añade valores extra a Props

- Reordena campos, variantes y métodos para mayor consistencia interna.
- Añade `extra_or`, `extra_or_default` y `extra_or_else` a `Props`.
- Mejora la documentación y amplía los tests de integración.
This commit is contained in:
Manuel Cillero 2026-07-09 00:05:28 +02:00
parent a26aa4d30a
commit 25713a8a13
26 changed files with 578 additions and 227 deletions

View file

@ -21,7 +21,7 @@ use crate::theme::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Container { 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, props: Props,
/// Devuelve el tipo semántico del contenedor. /// Devuelve el tipo semántico del contenedor.
container_kind: bs::container::Kind, container_kind: bs::container::Kind,
@ -141,7 +141,7 @@ impl Container {
self 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: /// También acepta clases predefinidas para:
/// ///

View file

@ -39,7 +39,7 @@ use crate::theme::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Dropdown { 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, props: Props,
/// Devuelve el título del menú desplegable. /// Devuelve el título del menú desplegable.
title: L10n, title: L10n,
@ -185,7 +185,7 @@ impl Dropdown {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -45,7 +45,7 @@ pub enum ItemKind {
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú. /// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item { 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, props: Props,
/// Devuelve el tipo de elemento representado. /// Devuelve el tipo de elemento representado.
item_kind: ItemKind, item_kind: ItemKind,
@ -266,7 +266,7 @@ impl Item {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -15,7 +15,7 @@ pub enum IconKind {
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Icon { 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, props: Props,
icon_kind: IconKind, icon_kind: IconKind,
aria_label: AttrL10n, aria_label: AttrL10n,
@ -110,7 +110,7 @@ impl Icon {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -14,7 +14,7 @@ use crate::theme::*;
/// - Aplicar el texto alternativo `alt` con **localización** mediante [`L10n`]. /// - Aplicar el texto alternativo `alt` con **localización** mediante [`L10n`].
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Image { 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, props: Props,
/// Devuelve las dimensiones de la imagen. /// Devuelve las dimensiones de la imagen.
size: bs::image::Size, size: bs::image::Size,
@ -86,7 +86,7 @@ impl Image {
self 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: /// También acepta clases predefinidas para:
/// ///

View file

@ -34,7 +34,7 @@ use crate::theme::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Nav { 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, props: Props,
/// Devuelve el estilo visual seleccionado. /// Devuelve el estilo visual seleccionado.
nav_kind: bs::nav::Kind, nav_kind: bs::nav::Kind,
@ -103,7 +103,7 @@ impl Nav {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -59,7 +59,7 @@ impl ItemKind {
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú. /// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item { 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, props: Props,
/// Devuelve el tipo de elemento representado. /// Devuelve el tipo de elemento representado.
item_kind: ItemKind, item_kind: ItemKind,
@ -271,7 +271,7 @@ impl Item {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -137,7 +137,7 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Navbar { 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, props: Props,
/// Devuelve el punto de ruptura configurado. /// Devuelve el punto de ruptura configurado.
expand: token::BreakPoint, expand: token::BreakPoint,
@ -346,7 +346,7 @@ impl Navbar {
self 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: /// También acepta clases predefinidas para:
/// ///

View file

@ -43,7 +43,7 @@ use crate::theme::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Offcanvas { 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, props: Props,
/// Devuelve el título del panel. /// Devuelve el título del panel.
title: L10n, title: L10n,
@ -100,7 +100,7 @@ impl Offcanvas {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -6,7 +6,7 @@ use crate::prelude::*;
/// opcional y un cuerpo que sólo se renderiza si existen componentes hijos (*children*). /// opcional y un cuerpo que sólo se renderiza si existen componentes hijos (*children*).
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Block { 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, props: Props,
/// Devuelve el título del bloque. /// Devuelve el título del bloque.
title: L10n, title: L10n,
@ -60,7 +60,7 @@ impl Block {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -65,7 +65,7 @@ impl fmt::Display for ButtonAction {
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Button { 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, props: Props,
/// Devuelve el comportamiento del botón al activarse. /// Devuelve el comportamiento del botón al activarse.
kind: ButtonAction, kind: ButtonAction,
@ -158,7 +158,7 @@ impl Button {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -105,7 +105,7 @@ impl Item {
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field { 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, props: Props,
/// Devuelve el nombre base compartido por todas las casillas del grupo. /// Devuelve el nombre base compartido por todas las casillas del grupo.
name: AttrName, name: AttrName,
@ -201,7 +201,7 @@ impl Field {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -39,7 +39,7 @@ use crate::prelude::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Checkbox { 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, props: Props,
/// Devuelve la variante visual del control. /// Devuelve la variante visual del control.
checkbox_kind: form::CheckboxKind, checkbox_kind: form::CheckboxKind,
@ -158,7 +158,7 @@ impl Checkbox {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -44,7 +44,7 @@ use crate::base::component::form;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Form { 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, props: Props,
/// Devuelve la URL/ruta de destino del formulario. /// Devuelve la URL/ruta de destino del formulario.
action: AttrValue, action: AttrValue,
@ -99,7 +99,7 @@ impl Form {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -24,7 +24,7 @@ use crate::prelude::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Fieldset { 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, props: Props,
/// Devuelve la leyenda del `fieldset`. /// Devuelve la leyenda del `fieldset`.
legend: Attr<L10n>, legend: Attr<L10n>,
@ -77,7 +77,7 @@ impl Fieldset {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -123,7 +123,7 @@ impl fmt::Display for Mode {
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field { 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, props: Props,
/// Devuelve el tipo de campo. /// Devuelve el tipo de campo.
kind: Kind, kind: Kind,
@ -302,7 +302,7 @@ impl Field {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -94,7 +94,7 @@ impl Item {
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field { 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, props: Props,
/// Devuelve el nombre compartido por todos los botones de opción del grupo. /// Devuelve el nombre compartido por todos los botones de opción del grupo.
name: AttrName, name: AttrName,
@ -204,7 +204,7 @@ impl Field {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -31,7 +31,7 @@ use crate::prelude::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Range { 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, props: Props,
/// Devuelve el nombre del campo. /// Devuelve el nombre del campo.
name: AttrName, name: AttrName,
@ -112,7 +112,7 @@ impl Range {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -188,7 +188,7 @@ pub enum Entry {
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field { 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, props: Props,
/// Devuelve el nombre del campo. /// Devuelve el nombre del campo.
name: AttrName, name: AttrName,
@ -312,7 +312,7 @@ impl Field {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -33,7 +33,7 @@ use crate::prelude::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Textarea { 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, props: Props,
/// Devuelve el nombre del campo. /// Devuelve el nombre del campo.
name: AttrName, name: AttrName,
@ -142,7 +142,7 @@ impl Textarea {
self self
} }
/// Modifica identificador, clases CSS o atributos HTML del componente. /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op); self.props.alter_prop(op);

View file

@ -144,7 +144,7 @@ pub trait Contextual: LangId {
#[builder_fn] #[builder_fn]
fn with_assets(self, op: AssetsOp) -> Self; fn with_assets(self, op: AssetsOp) -> Self;
/// Modifica identificador, clases CSS o atributos HTML del elemento `<body>`. /// Modifica identificador, clases CSS, atributos HTML o valores extra del elemento `<body>`.
#[builder_fn] #[builder_fn]
fn with_body_props(self, op: PropsOp) -> Self; 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. /// Devuelve los scripts JavaScript de los recursos del contexto.
fn javascripts(&self) -> &Assets<JavaScript>; fn javascripts(&self) -> &Assets<JavaScript>;
/// Devuelve identificador, clases CSS y atributos HTML del elemento `<body>`. /// Devuelve identificador, clases CSS, atributos HTML y valores extra del elemento `<body>`.
fn body_props(&self) -> &Props; fn body_props(&self) -> &Props;
// **< Contextual HELPERS >********************************************************************* // **< Contextual HELPERS >*********************************************************************

View file

@ -24,7 +24,7 @@ mod attr;
pub use attr::{Attr, AttrName, AttrValue}; pub use attr::{Attr, AttrName, AttrValue};
mod props; mod props;
pub use props::{Props, PropsOp}; pub use props::{Props, PropsError, PropsExtra, PropsOp};
mod unit; mod unit;
pub use unit::UnitValue; pub use unit::UnitValue;

View file

@ -1,50 +1,133 @@
use crate::core::TypeInfo;
use crate::html::maud::{Escaper, Render}; use crate::html::maud::{Escaper, Render};
use crate::{AutoDefault, CowStr, builder_fn, util}; 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<dyn Any + Send + Sync>,
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 >************************************************************************************ // **< 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 /// 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), /// recurrir a los constructores asociados como [`set_id()`](Self::set_id),
/// [`remove()`](Self::remove), [`add_classes()`](Self::add_classes), etc. /// [`add_classes()`](Self::add_classes), [`set()`](Self::set), etc.
/// ///
/// Las variantes `*Id` operan sobre el atributo `id` del componente. Cuando se usa `"id"` como /// 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 /// Las variantes `*Classes` gestionan la lista de clases CSS. Además, `Set("class", ...)`
/// se usa `"class"` como nombre en `Set` o `Remove` la operación se aplica a la lista de clases /// reemplaza la lista completa y `Remove("class")` la vacía.
/// completa. Así, `Set("class", ...)` reemplaza la lista de clases completa por las nuevas clases ///
/// indicadas, y `Remove("class")` vacía la lista de clases. /// Las variantes [`Set`](Self::Set) y [`Remove`](Self::Remove) son operaciones de propósito
#[derive(Clone, Debug, PartialEq)] /// 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 { pub enum PropsOp {
/// Establece el identificador del elemento normalizando el valor: recorta espacios, convierte a /// Establece el identificador del componente normalizando el valor: recorta espacios, convierte
/// minúsculas y sustituye los espacios intermedios por `_`. Si el resultado es vacío, elimina /// a minúsculas y sustituye los espacios intermedios por `_`. Si el resultado es vacío, elimina
/// el identificador. /// el identificador.
SetId(CowStr), SetId(CowStr),
/// Establece el identificador del elemento si aún no hay ninguno definido, de modo que no /// Establece el identificador del componente **sólo si aún no hay ninguno definido**. Aplica la
/// sobrescribe un valor asignado con anterioridad. Aplica la misma normalización que /// misma normalización que [`SetId`](Self::SetId); si el resultado es vacío, la operación no
/// [`SetId`](Self::SetId); si el resultado es vacío, la operación tampoco tiene efecto. /// tiene efecto.
EnsureId(CowStr), EnsureId(CowStr),
/// Añade el atributo o sustituye su valor si ya existe. Usar `"id"` como nombre aplica la misma /// Añade la clase o clases que no existan al final de la lista. La operación se ignora si el
/// normalización que [`SetId`](Self::SetId). Usar `"class"` como nombre reemplaza la lista /// valor contiene caracteres no ASCII.
/// 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.
AddClasses(CowStr), AddClasses(CowStr),
/// Añade las clases que no existan al principio de la lista. La operación se ignora si el valor /// Añade la clase o clases que no existan al principio de la lista. La operación se ignora si
/// contiene caracteres no ASCII. /// el valor contiene caracteres no ASCII.
PrependClasses(CowStr), 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. /// caracteres no ASCII.
RemoveClasses(CowStr), 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 { impl PropsOp {
@ -58,6 +141,21 @@ impl PropsOp {
Self::EnsureId(id.into()) Self::EnsureId(id.into())
} }
/// Crea la variante [`AddClasses`](Self::AddClasses) con la clase o clases indicadas.
pub fn add_classes(classes: impl Into<CowStr>) -> Self {
Self::AddClasses(classes.into())
}
/// Crea la variante [`PrependClasses`](Self::PrependClasses) con la clase o clases indicadas.
pub fn prepend_classes(classes: impl Into<CowStr>) -> Self {
Self::PrependClasses(classes.into())
}
/// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con la clase o clases indicadas.
pub fn remove_classes(classes: impl Into<CowStr>) -> Self {
Self::RemoveClasses(classes.into())
}
/// Crea la variante [`Set`](Self::Set) con nombre y valor del atributo. /// Crea la variante [`Set`](Self::Set) con nombre y valor del atributo.
pub fn set(name: impl Into<CowStr>, value: impl Into<CowStr>) -> Self { pub fn set(name: impl Into<CowStr>, value: impl Into<CowStr>) -> Self {
Self::Set(name.into(), value.into()) Self::Set(name.into(), value.into())
@ -68,28 +166,37 @@ impl PropsOp {
Self::Remove(name.into()) Self::Remove(name.into())
} }
/// Crea la variante [`AddClasses`](Self::AddClasses) con las clases indicadas. /// Crea la variante [`SetExtra`](Self::SetExtra) con la clave y el valor indicados.
pub fn add_classes(classes: impl Into<CowStr>) -> Self { ///
Self::AddClasses(classes.into()) /// ```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<T: Any + Send + Sync + 'static>(key: &'static str, value: T) -> Self {
Self::SetExtra(
key,
PropsExtra {
value: Arc::new(value),
type_name: TypeInfo::FullName.of::<T>(),
},
)
} }
/// Crea la variante [`PrependClasses`](Self::PrependClasses) con las clases indicadas. /// Crea la variante [`RemoveExtra`](Self::RemoveExtra) para la clave indicada.
pub fn prepend_classes(classes: impl Into<CowStr>) -> Self { pub fn remove_extra(key: &'static str) -> Self {
Self::PrependClasses(classes.into()) Self::RemoveExtra(key)
}
/// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con las clases indicadas.
pub fn remove_classes(classes: impl Into<CowStr>) -> Self {
Self::RemoveClasses(classes.into())
} }
} }
// **< Props >************************************************************************************** // **< 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 /// Al renderizar con [`html!`](crate::html::html) se emite primero el identificador `id` (si
/// el resto de atributos. /// existe), luego `class` (si hay clases) y después el resto de atributos, normalmente aplicados al
/// elemento raíz del componente.
/// ///
/// # Ejemplo /// # Ejemplo
/// ///
@ -111,7 +218,7 @@ impl PropsOp {
/// ///
/// # Identificadores /// # 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 /// identificador del componente: recorta espacios, convierte a minúsculas y sustituye los espacios
/// intermedios por `_`. /// intermedios por `_`.
/// ///
@ -122,8 +229,8 @@ impl PropsOp {
/// assert_eq!(markup.into_string(), r#"<button id="my_button">OK</button>"#); /// assert_eq!(markup.into_string(), r#"<button id="my_button">OK</button>"#);
/// ``` /// ```
/// ///
/// [`EnsureId`](PropsOp::EnsureId) (usando [`PropsOp::ensure_id`]) sólo asigna si no /// [`EnsureId`](PropsOp::EnsureId) (usando [`PropsOp::ensure_id()`]) sólo asigna si no hay
/// hay identificador previo: /// identificador previo:
/// ///
/// ```rust /// ```rust
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
@ -150,6 +257,33 @@ impl PropsOp {
/// assert_eq!(markup.into_string(), r#"<button class="btn btn-primary active">OK</button>"#); /// assert_eq!(markup.into_string(), r#"<button class="btn btn-primary active">OK</button>"#);
/// ``` /// ```
/// ///
/// # 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 /// # Integración en componentes
/// ///
/// El patrón recomendado es añadir un campo `props: Props` con su método *builder* delegado: /// 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 { /// 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] /// #[builder_fn]
/// pub fn with_prop(mut self, op: PropsOp) -> Self { /// pub fn with_prop(mut self, op: PropsOp) -> Self {
/// self.props.alter_prop(op); /// self.props.alter_prop(op);
@ -187,8 +321,9 @@ impl PropsOp {
#[derive(AutoDefault, Clone, Debug)] #[derive(AutoDefault, Clone, Debug)]
pub struct Props { pub struct Props {
id: Option<String>, id: Option<String>,
attrs: Vec<(CowStr, CowStr)>,
classes: Vec<String>, classes: Vec<String>,
attrs: Vec<(CowStr, CowStr)>,
extras: HashMap<&'static str, PropsExtra>,
} }
impl Props { impl Props {
@ -211,20 +346,21 @@ impl Props {
self 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. /// - [`PropsOp::set_id()`] - establece el identificador normalizando el valor.
/// - [`EnsureId(value)`](PropsOp::EnsureId) establece el identificador (con la misma /// - [`PropsOp::ensure_id()`] - establece el identificador sólo si no hay ninguno definido.
/// normalización) sólo si no hay ninguno definido. /// - [`PropsOp::add_classes()`] - añade clases al final (sin duplicados).
/// - [`Set(name, value)`](PropsOp::Set) añade el atributo o reemplaza su valor. /// - [`PropsOp::prepend_classes()`] - añade clases al principio (sin duplicados).
/// `Set("id", ...)` aplica la misma normalización que `SetId`. /// - [`PropsOp::remove_classes()`] - elimina las clases indicadas.
/// `Set("class", ...)` reemplaza la lista de clases completa. /// - [`PropsOp::set()`] - añade el atributo o reemplaza su valor. `set("id", ...)` aplica la
/// - [`Remove(name)`](PropsOp::Remove) elimina el atributo. `Remove("id")` elimina el /// misma normalización que `set_id()`. `set("class", ...)` reemplaza la lista de clases.
/// identificador. `Remove("class")` vacía la lista de clases. /// - [`PropsOp::remove()`] - elimina el atributo. `remove("id")` elimina el identificador;
/// - [`AddClasses(clases)`](PropsOp::AddClasses) añade clases al final (sin duplicados). /// `remove("class")` vacía la lista de clases.
/// - [`PrependClasses(clases)`](PropsOp::PrependClasses) añade clases al principio (sin /// - [`PropsOp::set_extra()`] - almacena un valor extra tipado.
/// duplicados). /// - [`PropsOp::remove_extra()`] - elimina el valor extra asociado a la clave.
/// - [`RemoveClasses(clases)`](PropsOp::RemoveClasses) elimina las clases indicadas.
#[builder_fn] #[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self { pub fn with_prop(mut self, op: PropsOp) -> Self {
match op { match op {
@ -236,31 +372,6 @@ impl Props {
self.apply_id(value.as_ref()); 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) => { PropsOp::AddClasses(classes) => {
let Some(normalized) = let Some(normalized) =
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
@ -291,18 +402,58 @@ impl Props {
.any(|r| r == c.as_str()) .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 self
} }
// **< Props GETTERS >************************************************************************** // **< Props GETTERS >**************************************************************************
/// Devuelve el identificador normalizado del elemento, si existe. /// Devuelve el identificador normalizado del componente, si existe.
#[inline] #[inline]
pub fn get_id(&self) -> Option<String> { pub fn get_id(&self) -> Option<String> {
self.id.clone() self.id.clone()
} }
/// Devuelve la lista de clases como cadena de texto, si hay clases definidas.
pub fn get_classes(&self) -> Option<String> {
if self.classes.is_empty() {
None
} else {
Some(self.classes.join(" "))
}
}
/// Devuelve el valor del atributo indicado, si existe. /// Devuelve el valor del atributo indicado, si existe.
/// ///
/// Los nombres `"id"` y `"class"` son equivalentes a llamar a [`get_id()`](Self::get_id) y /// 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<String> {
if self.classes.is_empty() {
None
} else {
Some(self.classes.join(" "))
}
}
/// Devuelve `true` si no hay ningún identificador definido. /// Devuelve `true` si no hay ningún identificador definido.
#[inline] #[inline]
pub fn is_id_empty(&self) -> bool { pub fn is_id_empty(&self) -> bool {
self.id.is_none() 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. /// Devuelve `true` si no hay ninguna clase definida.
#[inline] #[inline]
pub fn is_classes_empty(&self) -> bool { pub fn is_classes_empty(&self) -> bool {
self.classes.is_empty() 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] #[inline]
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.id.is_none() && self.attrs.is_empty() && self.classes.is_empty() 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)) .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::<i32>(EXT_COUNT).unwrap(), 7);
/// assert_eq!(props.extra::<i32>(EXT_OTHER), Err(PropsError::ExtraNotFound { key: EXT_OTHER }));
/// assert!(matches!(
/// props.extra::<u32>(EXT_COUNT),
/// Err(PropsError::ExtraTypeMismatch { .. })
/// ));
/// ```
pub fn extra<T: 'static>(&self, key: &'static str) -> Result<&T, PropsError> {
let ev = self
.extras
.get(key)
.ok_or(PropsError::ExtraNotFound { key })?;
ev.value
.downcast_ref::<T>()
.ok_or_else(|| PropsError::ExtraTypeMismatch {
key,
expected: TypeInfo::FullName.of::<T>(),
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<T: Clone + 'static>(&self, key: &'static str, default: T) -> T {
self.extra::<T>(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::<bool>(EXT_FLAG), false);
/// assert_eq!(props.extra_or_default::<i32>(EXT_COUNT), 0);
/// ```
pub fn extra_or_default<T: Clone + Default + 'static>(&self, key: &'static str) -> T {
self.extra::<T>(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: Clone + 'static, F: FnOnce() -> T>(
&self,
key: &'static str,
f: F,
) -> T {
self.extra::<T>(key).ok().cloned().unwrap_or_else(f)
}
// **< Props PRIVATE >************************************************************************** // **< Props PRIVATE >**************************************************************************
fn apply_id(&mut self, id: &str) { fn apply_id(&mut self, id: &str) {

View file

@ -23,35 +23,6 @@ async fn props_get_missing_key_returns_none() {
assert_eq!(p.get_prop(""), 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#"<button class="btn btn-primary">OK</button>"#
);
}
#[pagetop::test]
async fn props_classes_empty_input_renders_no_class_attribute() {
let p = Props::classes(" ");
assert_eq!(
html! { button (p) { "OK" } }.into_string(),
"<button>OK</button>"
);
}
#[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#"<button class="btn active">OK</button>"#
);
}
// **< PropsOp::set >******************************************************************************* // **< PropsOp::set >*******************************************************************************
#[pagetop::test] #[pagetop::test]
@ -222,7 +193,7 @@ async fn props_splice_empty_string_emits_nothing() {
assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>"); assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>");
} }
// **< is_attrs_empty / is_classes_empty / is_empty >*********************************************** // **< is_attrs_empty / is_empty >******************************************************************
#[pagetop::test] #[pagetop::test]
async fn props_is_attrs_empty_on_default() { 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()); assert!(!Props::classes("btn").is_empty());
} }
#[pagetop::test] // **< get_prop("id") >*****************************************************************************
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") >*********************************************************
#[pagetop::test] #[pagetop::test]
async fn get_prop_id_returns_none_by_default() { 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()); 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 >******************************************************************** // **< Regression & edge cases >********************************************************************
#[pagetop::test] #[pagetop::test]

View file

@ -11,7 +11,7 @@ fn assert_classes(p: &Props, expected: Option<&str>) {
); );
} }
// **< Construction & invariants (new/get) >******************************************************** // **< Props::classes (construction) >**************************************************************
#[pagetop::test] #[pagetop::test]
async fn classes_new_empty_and_whitespace_is_empty() { 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")); assert!(p.has_class("btn-primary"));
} }
#[pagetop::test] // **< PropsOp::add_classes >***********************************************************************
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) >*********************************************************
#[pagetop::test] #[pagetop::test]
async fn classes_add_appends_unique_and_normalizes() { async fn classes_add_appends_unique_and_normalizes() {
@ -49,7 +43,7 @@ async fn classes_add_ignores_empty_input() {
} }
#[pagetop::test] #[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")); let p = Props::classes("a b").with_prop(PropsOp::add_classes("A B a b"));
assert_classes(&p, Some("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")); assert_classes(&p, Some("a b"));
} }
// **< PropsOp::prepend_classes >*******************************************************************
#[pagetop::test] #[pagetop::test]
async fn classes_prepend_inserts_at_front_preserving_new_order() { async fn classes_prepend_inserts_at_front_preserving_new_order() {
let p = Props::classes("c d").with_prop(PropsOp::prepend_classes("A b")); 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")); assert_classes(&p, Some("a b"));
} }
// **< PropsOp::set / remove ("class") >************************************************************
#[pagetop::test] #[pagetop::test]
async fn classes_reset_replaces_entire_list_and_dedups() { 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")); 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")); 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] #[pagetop::test]
async fn classes_remove_is_case_insensitive() { async fn classes_remove_is_case_insensitive() {
@ -116,7 +120,7 @@ async fn classes_remove_with_extra_whitespace() {
assert_classes(&p, Some("a c")); assert_classes(&p, Some("a c"));
} }
// **< Queries (contains) >************************************************************************* // **< has_class / has_any_class >******************************************************************
#[pagetop::test] #[pagetop::test]
async fn classes_contains_single() { async fn classes_contains_single() {
@ -129,11 +133,9 @@ async fn classes_contains_single() {
#[pagetop::test] #[pagetop::test]
async fn classes_contains_all_and_any() { async fn classes_contains_all_and_any() {
let p = Props::classes("btn btn-primary active"); let p = Props::classes("btn btn-primary active");
assert!(p.has_class("btn active")); assert!(p.has_class("btn active"));
assert!(p.has_class("BTN BTN-PRIMARY")); assert!(p.has_class("BTN BTN-PRIMARY"));
assert!(!p.has_class("btn missing")); assert!(!p.has_class("btn missing"));
assert!(p.has_any_class("missing active")); assert!(p.has_any_class("missing active"));
assert!(p.has_any_class("BTN-PRIMARY missing")); assert!(p.has_any_class("BTN-PRIMARY missing"));
assert!(!p.has_any_class("missing other")); 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 ñ")); 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#"<button class="btn btn-primary">OK</button>"#
);
}
#[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#"<button class="btn active">OK</button>"#
);
}
// **< Combined sequences >*************************************************************************
#[pagetop::test] #[pagetop::test]
async fn classes_order_is_stable_for_existing_items() { async fn classes_order_is_stable_for_existing_items() {

120
tests/html_props_extras.rs Normal file
View file

@ -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::<bool>("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::<u32>("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::<bool>("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::<bool>("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::<u32>("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::<bool>("ext.flag"), false);
assert_eq!(props.extra_or_default::<i32>("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::<bool>("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::<bool>("ext.flag").unwrap(), true);
assert_eq!(*props.extra::<u32>("ext.count").unwrap(), 42);
assert_eq!(props.extra::<String>("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#"<button class="btn">OK</button>"#
);
}