♻️ (html): Migra Classes/ClassesOp a Props/PropsOp

Introduce `Props`/`PropsOp` para gestionar pares `atributo="valor"` y
clases CSS para aplicar en componentes.

- Constructores `Props::new()`, `Props::classes()` y `Props::default()`.
- `Page.body_classes` reemplazado por `body_props` (permite atributos
  arbitrarios en `<body>`, no sólo clases).
- Tests nuevos para atributos y reescritos para clases.
This commit is contained in:
Manuel Cillero 2026-06-12 01:55:07 +02:00
parent 0121fad94a
commit f9e87058d8
8 changed files with 707 additions and 319 deletions

View file

@ -80,10 +80,9 @@ impl Extension for SuperMenu {
))
.with_item(navbar::Item::nav(
Nav::new()
.with_classes(
ClassesOp::Add,
.with_prop(PropsOp::add_classes(
classes::Margin::with(Side::Start, ScaleSize::Auto).to_class(),
)
))
.with_item(nav::Item::link(L10n::t("menus_item_sign_up", &LOC), |cx| {
cx.route("/auth/sign-up")
}))

View file

@ -8,8 +8,8 @@ use crate::prelude::*;
pub struct Block {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al bloque.
classes: Classes,
/// Devuelve los atributos HTML y clases CSS del bloque.
props: Props,
/// Devuelve el título del bloque.
title: L10n,
/// Devuelve la lista de componentes hijo del bloque.
@ -26,7 +26,7 @@ impl Component for Block {
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, "block");
self.props.alter_prop(PropsOp::prepend_classes("block"));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
@ -39,7 +39,7 @@ impl Component for Block {
let id = cx.required_id::<Self>(self.id(), 1);
Ok(html! {
div id=(&id) class=[self.classes().get()] {
div id=(&id) (self.props()) {
@if let Some(title) = self.title().lookup(cx) {
h2 class="block__title" { span { (title) } }
}
@ -59,10 +59,10 @@ impl Block {
self
}
/// Modifica la lista de clases CSS aplicadas al bloque.
/// Modifica los atributos HTML o las clases CSS del bloque.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}

View file

@ -1,7 +1,7 @@
//! HTML en código.
mod maud;
pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, display, html, html_private};
pub(crate) mod maud;
pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html_private};
mod route;
pub use route::RoutePath;
@ -22,8 +22,8 @@ pub use logo::PageTopSvg;
mod attr;
pub use attr::{Attr, AttrId, AttrName, AttrValue};
mod classes;
pub use classes::{Classes, ClassesOp};
mod props;
pub use props::{Props, PropsOp};
mod unit;
pub use unit::UnitValue;

View file

@ -1,205 +0,0 @@
use crate::{AutoDefault, builder_fn, util};
use std::collections::HashSet;
/// Operaciones disponibles sobre la lista de clases en [`Classes`].
///
/// Cada variante opera sobre **una o más clases** proporcionadas como una cadena separada por
/// espacios (p. ej. `"btn active"`), que se normalizan internamente a minúsculas en
/// [`Classes::with_classes()`].
///
/// # Orden de las clases y CSS
///
/// El navegador aplica los estilos según la especificidad de los selectores y el orden en que las
/// reglas aparecen en la **hoja de estilos**, no por el orden de las clases en el atributo `class`.
/// Por tanto, `"btn active"` y `"active btn"` producen exactamente el mismo resultado visual.
///
/// Las operaciones [`Add`](Self::Add) y [`Prepend`](Self::Prepend) permiten controlar ese orden
/// únicamente por legibilidad o por convención de proyecto, no porque afecte al comportamiento
/// del navegador.
///
/// # Reemplazar una clase
///
/// Para sustituir una clase por otra encadena [`Remove`](Self::Remove) y [`Add`](Self::Add):
/// ```rust
/// # use pagetop::prelude::*;
/// let c = Classes::new("btn btn-primary active")
/// .with_classes(ClassesOp::Remove, "btn-primary")
/// .with_classes(ClassesOp::Add, "btn-secondary");
/// assert_eq!(c.get(), Some("btn active btn-secondary".to_string()));
/// ```
#[derive(AutoDefault, Clone, Debug, PartialEq)]
pub enum ClassesOp {
/// Añade las clases que no existan al final.
#[default]
Add,
/// Añade las clases que no existan al principio.
Prepend,
/// Elimina las clases indicadas que existan.
Remove,
/// Alterna presencia/ausencia de una o más clases.
///
/// Si en una misma llamada se repite una clase (p. ej. `"a a"`) que ya existe, el resultado
/// mantiene la pertenencia pero puede cambiar el orden (primero se elimina y luego se añade al
/// final).
Toggle,
/// Sustituye la lista completa por las clases indicadas.
Reset,
}
/// Lista de clases CSS normalizadas para el atributo `class` de HTML.
///
/// Permite construir y modificar dinámicamente con [`ClassesOp`] una lista de clases CSS
/// normalizadas.
///
/// # Normalización
///
/// - El orden de las clases no afecta al resultado en CSS; las operaciones de ordenación
/// ([`Add`](ClassesOp::Add), [`Prepend`](ClassesOp::Prepend)) son puramente estéticas.
/// - Solo se acepta una lista de clases con caracteres ASCII.
/// - Las clases se almacenan en minúsculas.
/// - No se permiten clases duplicadas tras la normalización (por ejemplo, `Btn` y `btn` se
/// consideran la misma clase).
/// - Las clases vacías se ignoran.
/// - Sin clases, [`get()`](Self::get) devuelve `None` (no `Some("")`).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let classes = Classes::new("Btn btn-primary")
/// .with_classes(ClassesOp::Add, "Active")
/// .with_classes(ClassesOp::Remove, "active")
/// .with_classes(ClassesOp::Add, "Disabled")
/// .with_classes(ClassesOp::Remove, "btn-primary");
///
/// assert_eq!(classes.get(), Some("btn disabled".to_string()));
/// assert!(classes.contains("disabled"));
/// ```
#[derive(AutoDefault, Clone, Debug)]
pub struct Classes(Vec<String>);
impl Classes {
/// Crea una nueva lista de clases a partir de la clase o clases proporcionadas en `classes`.
pub fn new(classes: impl AsRef<str>) -> Self {
Self::default().with_classes(ClassesOp::default(), classes)
}
// **< Classes BUILDER >************************************************************************
/// Modifica la lista de clases según la operación indicada.
///
/// Realiza la operación indicada en `op` para las clases proporcionadas en `classes` sobre la
/// lista de clases actual.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
let Some(normalized) =
util::normalize_ascii_or_empty(classes.as_ref(), "Classes::with_classes")
else {
return self;
};
match op {
ClassesOp::Add => {
self.add(normalized.as_ref().split_ascii_whitespace(), self.0.len());
}
ClassesOp::Prepend => {
self.add(normalized.as_ref().split_ascii_whitespace(), 0);
}
ClassesOp::Remove => {
let mut classes_to_remove = normalized.as_ref().split_ascii_whitespace();
// 0 clases: no se hace nada.
let Some(first) = classes_to_remove.next() else {
return self;
};
// 1 clase: un único *retain*, sin reservas extra.
let Some(second) = classes_to_remove.next() else {
self.0.retain(|c| c != first);
return self;
};
// 2+ clases: HashSet y un único *retain*.
let mut to_remove: HashSet<&str> = HashSet::new();
to_remove.insert(first);
to_remove.insert(second);
for class in classes_to_remove {
to_remove.insert(class);
}
self.0.retain(|c| !to_remove.contains(c.as_str()));
}
ClassesOp::Toggle => {
for class in normalized.as_ref().split_ascii_whitespace() {
if let Some(pos) = self.0.iter().position(|c| c == class) {
self.0.remove(pos);
} else {
self.0.push(class.to_string());
}
}
}
ClassesOp::Reset => {
self.0.clear();
self.add(normalized.as_ref().split_ascii_whitespace(), 0);
}
}
self
}
#[inline]
fn add<'a, I>(&mut self, classes: I, mut pos: usize)
where
I: IntoIterator<Item = &'a str>,
{
for class in classes {
// Inserción segura descartando duplicados.
if !self.0.iter().any(|c| c == class) {
let class = class.to_string();
if pos >= self.0.len() {
self.0.push(class);
} else {
self.0.insert(pos, class);
}
pos += 1;
}
}
}
// **< Classes GETTERS >************************************************************************
/// Devuelve `true` si no hay clases.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Devuelve la cadena de clases, si existe.
pub fn get(&self) -> Option<String> {
if self.0.is_empty() {
None
} else {
Some(self.0.join(" "))
}
}
/// Devuelve `true` si la clase o **todas** las clases indicadas están presentes.
pub fn contains(&self, classes: impl AsRef<str>) -> bool {
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
return false;
};
normalized
.as_ref()
.split_ascii_whitespace()
.all(|class| self.0.iter().any(|c| c == class))
}
/// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente.
pub fn contains_any(&self, classes: impl AsRef<str>) -> bool {
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
return false;
};
normalized
.as_ref()
.split_ascii_whitespace()
.any(|class| self.0.iter().any(|c| c == class))
}
}

313
src/html/props.rs Normal file
View file

@ -0,0 +1,313 @@
use crate::html::maud::{Escaper, Render};
use crate::{AutoDefault, CowStr, builder_fn, util};
use std::fmt::Write;
// **< PropsOp >************************************************************************************
/// Operaciones disponibles sobre atributos HTML y clases CSS en [`Props`].
///
/// Cada variante es autocontenida, lleva todos los datos que necesita para ejecutarse. El método
/// recomendado para construirlas es usar los constructores asociados ([`set()`](Self::set),
/// [`remove()`](Self::remove), [`add_classes()`](Self::add_classes), etc.).
///
/// Las variantes `*Classes` operan siempre sobre la lista de clases CSS para el componente.
///
/// Cuando se usa `"class"` como nombre de atributo 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)]
pub enum PropsOp {
/// Añade el atributo o sustituye su valor si ya existe. 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. Usar `"class"` como nombre vacía la lista de clases.
Remove(CowStr),
/// Añade las clases que no existan al final de la lista.
AddClasses(CowStr),
/// Añade las clases que no existan al principio de la lista.
PrependClasses(CowStr),
/// Elimina las clases indicadas de la lista.
RemoveClasses(CowStr),
}
impl PropsOp {
/// Crea la variante [`Set`](Self::Set) con nombre y valor del atributo.
pub fn set(name: impl Into<CowStr>, value: impl Into<CowStr>) -> Self {
Self::Set(name.into(), value.into())
}
/// Crea la variante [`Remove`](Self::Remove) para el atributo indicado.
pub fn remove(name: impl Into<CowStr>) -> Self {
Self::Remove(name.into())
}
/// Crea la variante [`AddClasses`](Self::AddClasses) con las clases indicadas.
pub fn add_classes(classes: impl Into<CowStr>) -> Self {
Self::AddClasses(classes.into())
}
/// Crea la variante [`PrependClasses`](Self::PrependClasses) con las clases indicadas.
pub fn prepend_classes(classes: impl Into<CowStr>) -> Self {
Self::PrependClasses(classes.into())
}
/// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con las clases indicadas.
pub fn remove_classes(classes: impl Into<CowStr>) -> Self {
Self::RemoveClasses(classes.into())
}
}
// **< Props >**************************************************************************************
/// Colección de pares `atributo="valor"` y clases CSS para aplicar en componentes.
///
/// Permite añadir dinámicamente pares `atributo="valor"` y clases CSS al elemento raíz de un
/// componente. Al renderizar los atributos en `html!` primero emite el atributo `class` (si hay
/// clases) y luego el resto de atributos.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let props = Props::new("hx-get", "/api/items")
/// .with_prop(PropsOp::set("hx-target", "#lista"))
/// .with_prop(PropsOp::set("hx-swap", "outerHTML"));
///
/// let markup = html! {
/// button (props) { "Cargar" }
/// };
///
/// assert_eq!(
/// markup.into_string(),
/// r##"<button hx-get="/api/items" hx-target="#lista" hx-swap="outerHTML">Cargar</button>"##
/// );
/// ```
///
/// # Clases CSS
///
/// ```rust
/// # use pagetop::prelude::*;
/// let props = Props::default()
/// .with_prop(PropsOp::add_classes("btn btn-primary"))
/// .with_prop(PropsOp::add_classes("active"));
///
/// let markup = html! { button (props) { "OK" } };
/// assert_eq!(markup.into_string(), r#"<button class="btn btn-primary active">OK</button>"#);
/// ```
///
/// # Integración en componentes
///
/// El patrón recomendado es añadir un campo `props: Props` con su método *builder* delegado:
///
/// ```rust
/// # use pagetop::prelude::*;
/// #[derive(AutoDefault, Clone, Getters)]
/// pub struct MyButton {
/// label: L10n,
/// props: Props,
/// }
///
/// impl Component for MyButton {
/// fn new() -> Self { Self::default() }
///
/// fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
/// Ok(html! {
/// button (self.props()) {
/// (self.label().using(cx))
/// }
/// })
/// }
/// }
///
/// impl MyButton {
/// /// Modifica los atributos HTML o las clases CSS del elemento raíz.
/// #[builder_fn]
/// pub fn with_prop(mut self, op: PropsOp) -> Self {
/// self.props.alter_prop(op);
/// self
/// }
/// }
/// ```
#[derive(AutoDefault, Clone, Debug)]
pub struct Props {
attrs: Vec<(CowStr, CowStr)>,
classes: Vec<String>,
}
impl Props {
/// Crea una colección con un primer atributo ya establecido.
pub fn new(name: impl Into<CowStr>, value: impl Into<CowStr>) -> Self {
Self::default().with_prop(PropsOp::set(name, value))
}
/// Crea una colección con las clases CSS iniciales indicadas.
pub fn classes(classes: impl Into<CowStr>) -> Self {
Self::default().with_prop(PropsOp::add_classes(classes))
}
// **< Props BUILDER >**************************************************************************
/// Modifica los atributos o clases según la operación indicada.
///
/// - [`Set(name, value)`](PropsOp::Set) añade el atributo o reemplaza su valor.
/// `Set("class", ...)` reemplaza la lista de clases completa.
/// - [`Remove(name)`](PropsOp::Remove) elimina el atributo. `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.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
match op {
PropsOp::Set(name, value) => {
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() == "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")
else {
return self;
};
let pos = self.classes.len();
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), pos);
}
PropsOp::PrependClasses(classes) => {
let Some(normalized) =
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
else {
return self;
};
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0);
}
PropsOp::RemoveClasses(classes) => {
let Some(normalized) =
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
else {
return self;
};
self.classes.retain(|c| {
!normalized
.as_ref()
.split_ascii_whitespace()
.any(|r| r == c.as_str())
});
}
}
self
}
// **< Props GETTERS >**************************************************************************
/// Devuelve el valor del atributo indicado, si existe.
pub fn get_prop(&self, name: impl AsRef<str>) -> Option<&str> {
let name = name.as_ref();
self.attrs
.iter()
.find(|(k, _)| k.as_ref() == name)
.map(|(_, v)| v.as_ref())
}
/// Devuelve `true` si no hay ningún atributo definido.
pub fn is_props_empty(&self) -> bool {
self.attrs.is_empty()
}
/// 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 ninguna clase definida.
pub fn is_classes_empty(&self) -> bool {
self.classes.is_empty()
}
/// Devuelve `true` si la clase o **todas** las clases indicadas están presentes.
pub fn has_class(&self, classes: impl AsRef<str>) -> bool {
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
return false;
};
normalized
.as_ref()
.split_ascii_whitespace()
.all(|class| self.classes.iter().any(|c| c == class))
}
/// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente.
pub fn has_any_class(&self, classes: impl AsRef<str>) -> bool {
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
return false;
};
normalized
.as_ref()
.split_ascii_whitespace()
.any(|class| self.classes.iter().any(|c| c == class))
}
// **< Props PRIVADO >**************************************************************************
#[inline]
fn insert_classes<'a, I>(&mut self, classes: I, mut pos: usize)
where
I: IntoIterator<Item = &'a str>,
{
for class in classes {
if !self.classes.iter().any(|c| c == class) {
let class = class.to_string();
if pos >= self.classes.len() {
self.classes.push(class);
} else {
self.classes.insert(pos, class);
}
pos += 1;
}
}
}
}
#[doc(hidden)]
impl Render for Props {
fn render_to(&self, w: &mut String) {
if let Some((first, rest)) = self.classes.split_first() {
w.push_str(" class=\"");
let _ = write!(Escaper::new(w), "{}", first);
for class in rest {
w.push(' ');
let _ = write!(Escaper::new(w), "{}", class);
}
w.push('"');
}
for (name, value) in &self.attrs {
w.push(' ');
let _ = write!(Escaper::new(w), "{}", name);
w.push_str("=\"");
let _ = write!(Escaper::new(w), "{}", value);
w.push('"');
}
}
}

View file

@ -20,8 +20,7 @@ use crate::base::action;
use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual};
use crate::core::theme::{DefaultRegion, Region, RegionRef, TemplateRef, ThemeRef};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
use crate::html::{Attr, AttrId};
use crate::html::{Classes, ClassesOp};
use crate::html::{Attr, AttrId, Props, PropsOp};
use crate::html::{DOCTYPE, Markup, html};
use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier};
use crate::web::HttpRequest;
@ -91,7 +90,7 @@ pub struct Page {
metadata : Vec<(&'static str, &'static str)>,
properties : Vec<(&'static str, &'static str)>,
body_id : AttrId,
body_classes: Classes,
body_props : Props,
context : Context,
}
@ -108,7 +107,7 @@ impl Page {
metadata : Vec::default(),
properties : Vec::default(),
body_id : AttrId::default(),
body_classes: Classes::default(),
body_props : Props::default(),
context : Context::new(Some(request)),
}
}
@ -150,10 +149,10 @@ impl Page {
self
}
/// Modifica las clases CSS del elemento `<body>` con una operación sobre [`Classes`].
/// Modifica los atributos HTML o las clases CSS del elemento `<body>`.
#[builder_fn]
pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.body_classes.alter_classes(op, classes);
pub fn with_body_props(mut self, op: PropsOp) -> Self {
self.body_props.alter_prop(op);
self
}
@ -184,9 +183,9 @@ impl Page {
&self.body_id
}
/// Devuelve las clases CSS del elemento `<body>`.
pub fn body_classes(&self) -> &Classes {
&self.body_classes
/// Devuelve los atributos HTML y clases CSS del elemento `<body>`.
pub fn body_props(&self) -> &Props {
&self.body_props
}
/// Devuelve una referencia mutable al [`Context`] de la página.
@ -262,7 +261,7 @@ impl Page {
head {
(head)
}
body id=[self.body_id().get()] class=[self.body_classes().get()] {
body id=[self.body_id().get()] (self.body_props()) {
(body)
}
}

View file

@ -1,7 +1,7 @@
use pagetop::prelude::*;
fn assert_classes(c: &Classes, expected: Option<&str>) {
let got = c.get();
fn assert_classes(p: &Props, expected: Option<&str>) {
let got = p.get_classes();
assert_eq!(
got.as_deref(),
expected,
@ -15,176 +15,154 @@ fn assert_classes(c: &Classes, expected: Option<&str>) {
#[pagetop::test]
async fn classes_new_empty_and_whitespace_is_empty() {
assert_classes(&Classes::new(""), None);
assert_classes(&Classes::new(" "), None);
assert_classes(&Classes::new("\t\n\r "), None);
assert_classes(&Props::classes(""), None);
assert_classes(&Props::classes(" "), None);
assert_classes(&Props::classes("\t\n\r "), None);
}
#[pagetop::test]
async fn classes_new_normalizes_and_dedups_and_preserves_first_occurrence_order() {
let c = Classes::new("Btn btn BTN btn-primary BTN-PRIMARY");
assert_classes(&c, Some("btn btn-primary"));
assert!(c.contains("BTN"));
assert!(c.contains("btn-primary"));
let p = Props::classes("Btn btn BTN btn-primary BTN-PRIMARY");
assert_classes(&p, Some("btn btn-primary"));
assert!(p.has_class("BTN"));
assert!(p.has_class("btn-primary"));
}
#[pagetop::test]
async fn classes_get_returns_none_when_empty_some_when_not() {
assert_classes(&Classes::new(" "), None);
assert_classes(&Classes::new("a"), Some("a"));
assert_classes(&Props::classes(" "), None);
assert_classes(&Props::classes("a"), Some("a"));
}
// **< Basic operations (add/prepend/set) >*********************************************************
#[pagetop::test]
async fn classes_add_appends_unique_and_normalizes() {
let c = Classes::new("a b").with_classes(ClassesOp::Add, "C b D");
assert_classes(&c, Some("a b c d"));
let p = Props::classes("a b").with_prop(PropsOp::add_classes("C b D"));
assert_classes(&p, Some("a b c d"));
}
#[pagetop::test]
async fn classes_add_ignores_empty_input() {
let c = Classes::new("a b").with_classes(ClassesOp::Add, " \t");
assert_classes(&c, Some("a b"));
let p = Props::classes("a b").with_prop(PropsOp::add_classes(" \t"));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_add_same_tokens() {
let c = Classes::new("a b").with_classes(ClassesOp::Add, "A B a b");
assert_classes(&c, Some("a b"));
let p = Props::classes("a b").with_prop(PropsOp::add_classes("A B a b"));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_add_rejects_non_ascii_is_noop() {
let c = Classes::new("a b").with_classes(ClassesOp::Add, "c ñ d");
assert_classes(&c, Some("a b"));
let p = Props::classes("a b").with_prop(PropsOp::add_classes("c ñ d"));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_prepend_inserts_at_front_preserving_new_order() {
let c = Classes::new("c d").with_classes(ClassesOp::Prepend, "A b");
assert_classes(&c, Some("a b c d"));
let p = Props::classes("c d").with_prop(PropsOp::prepend_classes("A b"));
assert_classes(&p, Some("a b c d"));
}
#[pagetop::test]
async fn classes_prepend_inserts_new_tokens_skipping_duplicates() {
let c = Classes::new("b c").with_classes(ClassesOp::Prepend, "a b d");
assert_classes(&c, Some("a d b c"));
let p = Props::classes("b c").with_prop(PropsOp::prepend_classes("a b d"));
assert_classes(&p, Some("a d b c"));
}
#[pagetop::test]
async fn classes_prepend_ignores_empty_input() {
let c = Classes::new("a b").with_classes(ClassesOp::Prepend, "");
assert_classes(&c, Some("a b"));
let p = Props::classes("a b").with_prop(PropsOp::prepend_classes(""));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_reset_replaces_entire_list_and_dedups() {
let c = Classes::new("a b c").with_classes(ClassesOp::Reset, "X y y Z");
assert_classes(&c, Some("x y z"));
let p = Props::classes("a b c").with_prop(PropsOp::set("class", "X y y Z"));
assert_classes(&p, Some("x y z"));
}
#[pagetop::test]
async fn classes_reset_with_empty_input_clears() {
let base = Classes::new("a b");
let c = base.with_classes(ClassesOp::Reset, " \n ");
assert_classes(&c, None);
let p = Props::classes("a b").with_prop(PropsOp::set("class", " \n "));
assert_classes(&p, None);
}
// **< Mutation operations (remove/toggle) >********************************************************
#[pagetop::test]
async fn classes_reset_with_non_ascii_is_noop() {
let p = Props::classes("a b").with_prop(PropsOp::set("class", "ñ"));
assert_classes(&p, Some("a b"));
}
// **< Mutation operations (remove) >***************************************************************
#[pagetop::test]
async fn classes_remove_is_case_insensitive() {
let c = Classes::new("a b c d").with_classes(ClassesOp::Remove, "B D");
assert_classes(&c, Some("a c"));
let p = Props::classes("a b c d").with_prop(PropsOp::remove_classes("B D"));
assert_classes(&p, Some("a c"));
}
#[pagetop::test]
async fn classes_remove_non_existing_is_noop() {
let c = Classes::new("a b c").with_classes(ClassesOp::Remove, "x y z");
assert_classes(&c, Some("a b c"));
let p = Props::classes("a b c").with_prop(PropsOp::remove_classes("x y z"));
assert_classes(&p, Some("a b c"));
}
#[pagetop::test]
async fn classes_remove_with_extra_whitespace() {
let c = Classes::new("a b c d").with_classes(ClassesOp::Remove, " b\t\t \n d ");
assert_classes(&c, Some("a c"));
}
#[pagetop::test]
async fn classes_toggle_removes_if_present_case_insensitive() {
let c = Classes::new("a b c").with_classes(ClassesOp::Toggle, "B");
assert_classes(&c, Some("a c"));
}
#[pagetop::test]
async fn classes_toggle_adds_if_missing_and_normalizes() {
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "C");
assert_classes(&c, Some("a b c"));
}
#[pagetop::test]
async fn classes_toggle_multiple_tokens_is_sequential_and_order_dependent() {
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "C B A");
assert_classes(&c, Some("c"));
}
#[pagetop::test]
async fn classes_toggle_duplicate_tokens_are_applied_sequentially() {
let c = Classes::new("b").with_classes(ClassesOp::Toggle, "a a");
assert_classes(&c, Some("b"));
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "a a");
assert_classes(&c, Some("b a"));
let p = Props::classes("a b c d").with_prop(PropsOp::remove_classes(" b\t\t \n d "));
assert_classes(&p, Some("a c"));
}
// **< Queries (contains) >*************************************************************************
#[pagetop::test]
async fn classes_contains_single() {
let c = Classes::new("btn btn-primary");
assert!(c.contains("btn"));
assert!(c.contains("BTN"));
assert!(!c.contains("missing"));
let p = Props::classes("btn btn-primary");
assert!(p.has_class("btn"));
assert!(p.has_class("BTN"));
assert!(!p.has_class("missing"));
}
#[pagetop::test]
async fn classes_contains_all_and_any() {
let c = Classes::new("btn btn-primary active");
let p = Props::classes("btn btn-primary active");
assert!(c.contains("btn active"));
assert!(c.contains("BTN BTN-PRIMARY"));
assert!(!c.contains("btn missing"));
assert!(p.has_class("btn active"));
assert!(p.has_class("BTN BTN-PRIMARY"));
assert!(!p.has_class("btn missing"));
assert!(c.contains_any("missing active"));
assert!(c.contains_any("BTN-PRIMARY missing"));
assert!(!c.contains_any("missing other"));
assert!(p.has_any_class("missing active"));
assert!(p.has_any_class("BTN-PRIMARY missing"));
assert!(!p.has_any_class("missing other"));
}
#[pagetop::test]
async fn classes_contains_empty_and_whitespace_is_false() {
let c = Classes::new("a b");
assert!(!c.contains(""));
assert!(!c.contains(" \t"));
assert!(!c.contains_any(""));
assert!(!c.contains_any(" \n "));
let p = Props::classes("a b");
assert!(!p.has_class(""));
assert!(!p.has_class(" \t"));
assert!(!p.has_any_class(""));
assert!(!p.has_any_class(" \n "));
}
#[pagetop::test]
async fn classes_contains_non_ascii_is_false() {
let c = Classes::new("a b");
assert!(!c.contains("ñ"));
assert!(!c.contains_any("a ñ"));
let p = Props::classes("a b");
assert!(!p.has_class("ñ"));
assert!(!p.has_any_class("a ñ"));
}
// **< Properties / regression (combined sequences, ordering) >*************************************
#[pagetop::test]
async fn classes_order_is_stable_for_existing_items() {
let c = Classes::new("a b c")
.with_classes(ClassesOp::Add, "d") // a b c d
.with_classes(ClassesOp::Prepend, "x") // x a b c d
.with_classes(ClassesOp::Remove, "b") // x a c d
.with_classes(ClassesOp::Add, "b"); // x a c d b
assert_classes(&c, Some("x a c d b"));
let p = Props::classes("a b c")
.with_prop(PropsOp::add_classes("d")) // a b c d
.with_prop(PropsOp::prepend_classes("x")) // x a b c d
.with_prop(PropsOp::remove_classes("b")) // x a c d
.with_prop(PropsOp::add_classes("b")); // x a c d b
assert_classes(&p, Some("x a c d b"));
}

304
tests/html_props.rs Normal file
View file

@ -0,0 +1,304 @@
use pagetop::prelude::*;
// **< Construction & invariants >******************************************************************
#[pagetop::test]
async fn props_default_renders_nothing() {
assert_eq!(
html! { span (Props::default()) {} }.into_string(),
"<span></span>"
);
}
#[pagetop::test]
async fn props_new_creates_first_attr() {
let p = Props::new("hx-get", "/api");
assert_eq!(p.get_prop("hx-get"), Some("/api"));
}
#[pagetop::test]
async fn props_get_missing_key_returns_none() {
let p = Props::new("hx-get", "/api");
assert_eq!(p.get_prop("hx-post"), 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 >*******************************************************************************
#[pagetop::test]
async fn props_set_adds_new_attrs() {
let p = Props::default()
.with_prop(PropsOp::set("hx-get", "/api"))
.with_prop(PropsOp::set("hx-swap", "outerHTML"));
assert_eq!(p.get_prop("hx-get"), Some("/api"));
assert_eq!(p.get_prop("hx-swap"), Some("outerHTML"));
}
#[pagetop::test]
async fn props_set_replaces_existing_value() {
let p = Props::new("hx-get", "/old").with_prop(PropsOp::set("hx-get", "/new"));
assert_eq!(p.get_prop("hx-get"), Some("/new"));
}
#[pagetop::test]
async fn props_set_does_not_create_duplicate_key() {
// Reasignar la misma clave debe reemplazar el valor, no añadir una entrada duplicada.
let p = Props::new("key", "v1").with_prop(PropsOp::set("key", "v2"));
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span key="v2"></span>"#
);
}
#[pagetop::test]
async fn props_set_preserves_insertion_order() {
let p = Props::new("a", "1")
.with_prop(PropsOp::set("b", "2"))
.with_prop(PropsOp::set("c", "3"));
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span a="1" b="2" c="3"></span>"#
);
}
// **< PropsOp::remove >****************************************************************************
#[pagetop::test]
async fn props_remove_existing_attr() {
let p = Props::new("a", "1")
.with_prop(PropsOp::set("b", "2"))
.with_prop(PropsOp::remove("a"));
assert_eq!(p.get_prop("a"), None);
assert_eq!(p.get_prop("b"), Some("2"));
}
#[pagetop::test]
async fn props_remove_nonexistent_key_is_noop() {
let p = Props::new("a", "1").with_prop(PropsOp::remove("missing"));
assert_eq!(p.get_prop("a"), Some("1"));
assert_eq!(p.get_prop("missing"), None);
}
#[pagetop::test]
async fn props_renders_nothing_after_removing_last_attr() {
let p = Props::new("only", "one").with_prop(PropsOp::remove("only"));
assert_eq!(html! { span (p) {} }.into_string(), "<span></span>");
}
// **< HTML Escaped >*******************************************************************************
#[pagetop::test]
async fn props_escapes_ampersand_and_angle_brackets_in_value() {
let p = Props::new("data-info", "a&b<c>d");
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span data-info="a&amp;b&lt;c&gt;d"></span>"#
);
}
#[pagetop::test]
async fn props_escapes_double_quotes_in_value() {
let p = Props::new("data-label", r#"say "hello""#);
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span data-label="say &quot;hello&quot;"></span>"#
);
}
// **< Integration with html! >*********************************************************************
#[pagetop::test]
async fn props_empty_in_html_macro_produces_no_attributes() {
// Una Props vacía no debe emitir ni siquiera un espacio en blanco extra.
let p = Props::default();
assert_eq!(
html! { button (p) { "x" } }.into_string(),
"<button>x</button>"
);
}
#[pagetop::test]
async fn props_single_attr_in_html_macro() {
let p = Props::new("hx-get", "/api");
assert_eq!(
html! { button (p) { "Load" } }.into_string(),
r#"<button hx-get="/api">Load</button>"#
);
}
#[pagetop::test]
async fn props_multiple_attrs_preserve_order_in_html_macro() {
let p = Props::new("hx-get", "/api")
.with_prop(PropsOp::set("hx-target", "#result"))
.with_prop(PropsOp::set("hx-swap", "outerHTML"));
assert_eq!(
html! { button (p) {} }.into_string(),
r##"<button hx-get="/api" hx-target="#result" hx-swap="outerHTML"></button>"##
);
}
#[pagetop::test]
async fn props_alongside_class_and_id_in_html_macro() {
// El splice siempre se emite después de class e id, independientemente del orden escrito.
let p = Props::new("hx-get", "/api");
assert_eq!(
html! { button #mybtn .btn (p) { "Go" } }.into_string(),
r#"<button class="btn" id="mybtn" hx-get="/api">Go</button>"#
);
}
#[pagetop::test]
async fn props_alongside_named_attr_renders_after_it() {
let p = Props::new("hx-get", "/api");
assert_eq!(
html! { button type="button" (p) {} }.into_string(),
r#"<button type="button" hx-get="/api"></button>"#
);
}
#[pagetop::test]
async fn props_multiple_splices_in_same_element() {
let p1 = Props::new("hx-get", "/api");
let p2 = Props::new("hx-swap", "outerHTML");
assert_eq!(
html! { button (p1) (p2) {} }.into_string(),
r#"<button hx-get="/api" hx-swap="outerHTML"></button>"#
);
}
#[pagetop::test]
async fn props_inline_construction_in_html_macro() {
assert_eq!(
html! { button (Props::new("hx-get", "/api")) { "Go" } }.into_string(),
r#"<button hx-get="/api">Go</button>"#
);
}
#[pagetop::test]
async fn props_conditional_expression_in_html_macro() {
for (active, expected) in [
(true, r#"<button hx-get="/api">x</button>"#),
(false, "<button>x</button>"),
] {
let markup = html! {
button (if active { Props::new("hx-get", "/api") } else { Props::default() }) { "x" }
};
assert_eq!(markup.into_string(), expected);
}
}
#[pagetop::test]
async fn props_splice_empty_string_emits_nothing() {
// Un splice vacío no emite ningún atributo ni espacio extra.
assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>");
}
// **< is_props_empty / is_classes_empty >**********************************************************
#[pagetop::test]
async fn props_is_props_empty_on_default() {
assert!(Props::default().is_props_empty());
}
#[pagetop::test]
async fn props_is_props_empty_false_after_set() {
assert!(!Props::new("hx-get", "/api").is_props_empty());
}
#[pagetop::test]
async fn props_is_props_empty_true_after_removing_last_attr() {
let p = Props::new("only", "one").with_prop(PropsOp::remove("only"));
assert!(p.is_props_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());
}
// **< Regression & edge cases >********************************************************************
#[pagetop::test]
async fn props_hx_target_value_with_hash_renders_correctly() {
// Regresión: r#"..."# se cerraba prematuramente al encontrar `"#lista"`.
let p = Props::new("hx-target", "#list");
assert_eq!(
html! { button (p) {} }.into_string(),
r##"<button hx-target="#list"></button>"##
);
}
#[pagetop::test]
async fn props_with_empty_value_renders_attr_with_empty_value() {
let p = Props::new("data-expanded", "");
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span data-expanded=""></span>"#
);
}
#[pagetop::test]
async fn props_chained_set_and_remove_yields_expected_state() {
let p = Props::new("a", "1")
.with_prop(PropsOp::set("b", "2"))
.with_prop(PropsOp::set("c", "3"))
.with_prop(PropsOp::remove("b"))
.with_prop(PropsOp::set("a", "updated"));
assert_eq!(p.get_prop("a"), Some("updated"));
assert_eq!(p.get_prop("b"), None);
assert_eq!(p.get_prop("c"), Some("3"));
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span a="updated" c="3"></span>"#
);
}
#[pagetop::test]
async fn props_with_empty_attr_name_renders_without_validation() {
// Comportamiento documentado: los nombres no se validan; el HTML resultante no es estándar.
let p = Props::new("", "val");
assert_eq!(
html! { span (p) {} }.into_string(),
r#"<span ="val"></span>"#
);
}