diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index 7f8ccdda..06c4ed36 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -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") })) diff --git a/src/base/component/block.rs b/src/base/component/block.rs index 01f10d42..b583313b 100644 --- a/src/base/component/block.rs +++ b/src/base/component/block.rs @@ -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 { @@ -39,7 +39,7 @@ impl Component for Block { let id = cx.required_id::(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) -> Self { - self.classes.alter_classes(op, classes); + pub fn with_prop(mut self, op: PropsOp) -> Self { + self.props.alter_prop(op); self } diff --git a/src/html.rs b/src/html.rs index 6020627d..bff37698 100644 --- a/src/html.rs +++ b/src/html.rs @@ -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; diff --git a/src/html/classes.rs b/src/html/classes.rs deleted file mode 100644 index 903475ec..00000000 --- a/src/html/classes.rs +++ /dev/null @@ -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); - -impl Classes { - /// Crea una nueva lista de clases a partir de la clase o clases proporcionadas en `classes`. - pub fn new(classes: impl AsRef) -> 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) -> 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, - { - 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 { - 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) -> 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) -> 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)) - } -} diff --git a/src/html/props.rs b/src/html/props.rs new file mode 100644 index 00000000..ecf63a2b --- /dev/null +++ b/src/html/props.rs @@ -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, value: impl Into) -> Self { + Self::Set(name.into(), value.into()) + } + + /// Crea la variante [`Remove`](Self::Remove) para el atributo indicado. + pub fn remove(name: impl Into) -> Self { + Self::Remove(name.into()) + } + + /// Crea la variante [`AddClasses`](Self::AddClasses) con las clases indicadas. + pub fn add_classes(classes: impl Into) -> Self { + Self::AddClasses(classes.into()) + } + + /// Crea la variante [`PrependClasses`](Self::PrependClasses) con las clases indicadas. + pub fn prepend_classes(classes: impl Into) -> Self { + Self::PrependClasses(classes.into()) + } + + /// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con las clases indicadas. + pub fn remove_classes(classes: impl Into) -> Self { + Self::RemoveClasses(classes.into()) + } +} + +// **< 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##""## +/// ); +/// ``` +/// +/// # 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#""#); +/// ``` +/// +/// # 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 { +/// 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, +} + +impl Props { + /// Crea una colección con un primer atributo ya establecido. + pub fn new(name: impl Into, value: impl Into) -> 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) -> 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) -> 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 { + 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) -> 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) -> 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, + { + 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('"'); + } + } +} diff --git a/src/response/page.rs b/src/response/page.rs index 2376fd39..4ca3f147 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -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 `` con una operación sobre [`Classes`]. + /// Modifica los atributos HTML o las clases CSS del elemento ``. #[builder_fn] - pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef) -> 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 ``. - pub fn body_classes(&self) -> &Classes { - &self.body_classes + /// Devuelve los atributos HTML y clases CSS del elemento ``. + 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) } } diff --git a/tests/html_classes.rs b/tests/html_classes.rs index e2198335..6beeca44 100644 --- a/tests/html_classes.rs +++ b/tests/html_classes.rs @@ -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")); } diff --git a/tests/html_props.rs b/tests/html_props.rs new file mode 100644 index 00000000..f622dd95 --- /dev/null +++ b/tests/html_props.rs @@ -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(), + "" + ); +} + +#[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#""# + ); +} + +#[pagetop::test] +async fn props_classes_empty_input_renders_no_class_attribute() { + let p = Props::classes(" "); + assert_eq!( + html! { button (p) { "OK" } }.into_string(), + "" + ); +} + +#[pagetop::test] +async fn props_classes_can_be_extended_with_with_prop() { + let p = Props::classes("btn").with_prop(PropsOp::add_classes("active")); + assert_eq!( + html! { button (p) { "OK" } }.into_string(), + r#""# + ); +} + +// **< PropsOp::set >******************************************************************************* + +#[pagetop::test] +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#""# + ); +} + +#[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#""# + ); +} + +// **< 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(), ""); +} + +// **< HTML Escaped >******************************************************************************* + +#[pagetop::test] +async fn props_escapes_ampersand_and_angle_brackets_in_value() { + let p = Props::new("data-info", "a&bd"); + assert_eq!( + html! { span (p) {} }.into_string(), + r#""# + ); +} + +#[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#""# + ); +} + +// **< 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(), + "" + ); +} + +#[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#""# + ); +} + +#[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##""## + ); +} + +#[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#""# + ); +} + +#[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#""# + ); +} + +#[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#""# + ); +} + +#[pagetop::test] +async fn props_inline_construction_in_html_macro() { + assert_eq!( + html! { button (Props::new("hx-get", "/api")) { "Go" } }.into_string(), + r#""# + ); +} + +#[pagetop::test] +async fn props_conditional_expression_in_html_macro() { + for (active, expected) in [ + (true, r#""#), + (false, ""), + ] { + 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(), "x"); +} + +// **< 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##""## + ); +} + +#[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#""# + ); +} + +#[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#""# + ); +} + +#[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#""# + ); +}