diff --git a/src/html/props.rs b/src/html/props.rs
index 22d1ae47..25cb10f0 100644
--- a/src/html/props.rs
+++ b/src/html/props.rs
@@ -1,6 +1,6 @@
use crate::core::TypeInfo;
use crate::html::maud::{Escaper, Render};
-use crate::{AutoDefault, CowStr, builder_fn, util};
+use crate::{AutoDefault, CowStr, builder_fn, trace, util};
use std::any::Any;
use std::collections::HashMap;
@@ -84,18 +84,22 @@ impl std::error::Error for PropsError {}
/// nombre de atributo en `Set`, el valor se normaliza igual que [`SetId`](Self::SetId).
///
/// Las variantes `*Classes` gestionan la lista de clases CSS. Además, `Set("class", ...)`
-/// reemplaza la lista completa y `Remove("class")` la vacía.
+/// reemplaza la lista completa de clases y `Remove("class")` la vacía.
+///
+/// Las variantes `*Style` gestionan las declaraciones de estilos para el atributo `style`, con una
+/// propiedad cada vez. Además, `Set("style", ...)` reemplaza la lista completa de estilos y
+/// `Remove("style")` la vacía.
///
/// Las variantes [`Set`](Self::Set) y [`Remove`](Self::Remove) son operaciones de propósito
-/// general: `Set` añade o reemplaza cualquier atributo HTML por nombre y valor, y `Remove` lo
-/// elimina. Los atributos `id` y `class` tienen semántica especial documentada en cada variante.
+/// general. `Set` añade o reemplaza cualquier atributo HTML por nombre y valor, y `Remove` lo
+/// elimina. Los atributos `id`, `class` y `style` 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.
+/// Las variantes `*Extra` permiten añadir valores tipados usando una clave. Están pensadas para
+/// ampliar el comportamiento de componentes ya existentes. Como no es posible añadir campos a la
+/// estructura de un componente ya definido, temas y extensiones pueden definir un *trait* con
+/// nuevos métodos que leen y escriben valores extra en [`Props`]. Esos valores se interpretan como
+/// si fueran valores internos del componente para tomar decisiones durante el renderizado.
#[derive(Clone, Debug)]
pub enum PropsOp {
/// Establece el identificador del componente normalizando el valor: recorta espacios, convierte
@@ -120,13 +124,31 @@ pub enum PropsOp {
/// Elimina la clase o clases indicadas de la lista. La operación se ignora si el valor contiene
/// caracteres no ASCII.
RemoveClasses(CowStr),
- /// Añade un atributo o sustituye su valor si ya existe. Usar `"id"` como nombre de atributo
- /// aplica al valor la misma normalización que [`SetId`](Self::SetId). Usar `"class"` como
- /// nombre de atributo reemplaza la lista completa de clases por las nuevas indicadas; la
- /// operación se ignora si el valor contiene caracteres no ASCII.
+ /// Añade una declaración de estilo (propiedad, valor) o sustituye su valor si la propiedad ya
+ /// existe, conservando su posición; si no, se añade al final. A diferencia de las clases, el
+ /// valor admite caracteres no ASCII (p. ej. `content`, `font-family`) y distingue mayúsculas y
+ /// minúsculas. El nombre de la propiedad se normaliza a minúsculas. Si la propiedad o el valor
+ /// quedan vacíos tras recortar espacios, la operación se ignora.
+ AddStyle(CowStr, CowStr),
+ /// Elimina la propiedad de estilo indicada, si existe.
+ RemoveStyle(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.
+ ///
+ /// Usar `"style"` como nombre de atributo reemplaza la lista completa de estilos por los nuevos
+ /// indicados, interpretando el valor como declaraciones `"propiedad: valor"` separadas por `;`
+ /// (igual que el propio atributo `style` HTML). El separador `;` respeta paréntesis y comillas,
+ /// tal que valores como una *data URI* (`background: url(data:image/png;base64,...)`) o una
+ /// cadena con `;` (`content: "a;b"`) se interpretan correctamente. En cualquier caso, se
+ /// recomienda usar [`PropsOp::add_style()`](Self::add_style) para declarar estilos.
Set(CowStr, CowStr),
/// Elimina el atributo indicado. Usar `"id"` elimina el identificador; usar `"class"` vacía la
- /// lista de clases.
+ /// lista de clases; y usar `"style"` vacía la lista de estilos.
Remove(CowStr),
/// Almacena un valor extra tipado asociado a la clave indicada. Si ya existe uno con esa clave,
/// lo reemplaza.
@@ -174,6 +196,26 @@ impl PropsOp {
Self::RemoveClasses(classes.into())
}
+ /// Crea la variante [`AddStyle`](Self::AddStyle) con la propiedad y el valor de estilo
+ /// indicados.
+ ///
+ /// ```rust
+ /// # use pagetop::prelude::*;
+ /// let props = Props::default()
+ /// .with_prop(PropsOp::add_style("color", "red"))
+ /// .with_prop(PropsOp::add_style("font-weight", "bold"))
+ /// .with_prop(PropsOp::add_style("color", "blue"));
+ /// assert_eq!(props.get_styles(), Some("color: blue; font-weight: bold".to_string()));
+ /// ```
+ pub fn add_style(property: impl Into, value: impl Into) -> Self {
+ Self::AddStyle(property.into(), value.into())
+ }
+
+ /// Crea la variante [`RemoveStyle`](Self::RemoveStyle) para la propiedad de estilo indicada.
+ pub fn remove_style(property: impl Into) -> Self {
+ Self::RemoveStyle(property.into())
+ }
+
/// Crea la variante [`Set`](Self::Set) con nombre y valor del atributo.
pub fn set(name: impl Into, value: impl Into) -> Self {
Self::Set(name.into(), value.into())
@@ -212,9 +254,10 @@ impl PropsOp {
/// Recoge el identificador, clases CSS, atributos HTML y valores extra de un componente.
///
-/// Al renderizar con [`html!`](crate::html::html) se emite primero el identificador `id` (si
-/// existe), luego `class` (si hay clases) y después el resto de atributos, normalmente aplicados al
-/// elemento raíz del componente.
+/// Guarda estos valores con operaciones [`PropsOp`]. Cuando se renderiza usando
+/// [`html!`](crate::html::html) se emite primero el identificador `id` (si existe), luego `class`
+/// (si hay clases), después `style` (si hay declaraciones de estilo) y por último el resto de
+/// atributos; normalmente se asignan al elemento raíz del componente.
///
/// # Ejemplo
///
@@ -276,6 +319,23 @@ impl PropsOp {
/// assert_eq!(markup.into_string(), r#""#);
/// ```
///
+/// # Estilos CSS
+///
+/// Cada declaración se añade indicando una propiedad y su valor. Si la propiedad ya existe,
+/// [`AddStyle`](PropsOp::AddStyle) sustituye su valor conservando la posición, sin duplicarla.
+///
+/// ```rust
+/// # use pagetop::prelude::*;
+/// let props = Props::default()
+/// .with_prop(PropsOp::add_style("color", "red"))
+/// .with_prop(PropsOp::add_style("font-weight", "bold"))
+/// .with_prop(PropsOp::add_style("color", "blue"))
+/// .with_prop(PropsOp::remove_style("font-weight"));
+///
+/// let markup = html! { button (props) { "OK" } };
+/// assert_eq!(markup.into_string(), r#""#);
+/// ```
+///
/// # Valores extra
///
/// Las variantes [`SetExtra`](PropsOp::SetExtra) y [`RemoveExtra`](PropsOp::RemoveExtra), usando
@@ -341,6 +401,7 @@ impl PropsOp {
pub struct Props {
id: Option,
classes: Vec,
+ styles: Vec<(CowStr, CowStr)>,
attrs: Vec<(CowStr, CowStr)>,
extras: HashMap<&'static str, PropsExtra>,
}
@@ -367,21 +428,7 @@ impl Props {
/// 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`]:
- ///
- /// - [`PropsOp::set_id()`] - establece el identificador normalizando el valor.
- /// - [`PropsOp::ensure_id()`] - establece el identificador sólo si no hay ninguno definido.
- /// - [`PropsOp::add_classes()`] - añade clases al final (sin duplicados).
- /// - [`PropsOp::prepend_classes()`] - añade clases al principio (sin duplicados).
- /// - [`PropsOp::replace_classes()`] - sustituye una o varias clases existentes por otras
- /// nuevas, preservando su posición.
- /// - [`PropsOp::remove_classes()`] - elimina las clases indicadas.
- /// - [`PropsOp::set()`] - añade el atributo o reemplaza su valor. `set("id", ...)` aplica la
- /// misma normalización que `set_id()`. `set("class", ...)` reemplaza la lista de clases.
- /// - [`PropsOp::remove()`] - elimina el atributo. `remove("id")` elimina el identificador;
- /// `remove("class")` vacía la lista de clases.
- /// - [`PropsOp::set_extra()`] - almacena un valor extra tipado.
- /// - [`PropsOp::remove_extra()`] - elimina el valor extra asociado a la clave.
+ /// [`PropsOp`].
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
match op {
@@ -445,6 +492,12 @@ impl Props {
.any(|r| r == c.as_str())
});
}
+ PropsOp::AddStyle(property, value) => {
+ self.set_style(property.as_ref(), value.as_ref());
+ }
+ PropsOp::RemoveStyle(property) => {
+ self.remove_style(property.as_ref());
+ }
PropsOp::Set(name, value) => {
if name.as_ref() == "id" {
self.apply_id(value.as_ref());
@@ -455,6 +508,9 @@ impl Props {
self.classes.clear();
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0);
}
+ } else if name.as_ref() == "style" {
+ self.styles.clear();
+ self.parse_styles(value.as_ref());
} else if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &name) {
self.attrs[pos].1 = value;
} else {
@@ -466,6 +522,8 @@ impl Props {
self.id = None;
} else if name.as_ref() == "class" {
self.classes.clear();
+ } else if name.as_ref() == "style" {
+ self.styles.clear();
} else {
self.attrs.retain(|(k, _)| k != &name);
}
@@ -497,14 +555,41 @@ impl Props {
}
}
+ /// Devuelve las declaraciones de estilo como cadena de texto (separadas por `"; "`), si hay
+ /// estilos definidos.
+ pub fn get_styles(&self) -> Option {
+ if self.styles.is_empty() {
+ None
+ } else {
+ Some(
+ self.styles
+ .iter()
+ .map(|(k, v)| format!("{k}: {v}"))
+ .collect::>()
+ .join("; "),
+ )
+ }
+ }
+
+ /// Devuelve el valor de la propiedad de estilo indicada, si existe.
+ pub fn get_style(&self, property: impl AsRef) -> Option {
+ let property = property.as_ref().trim().to_ascii_lowercase();
+ self.styles
+ .iter()
+ .find(|(k, _)| k.as_ref() == property)
+ .map(|(_, v)| v.to_string())
+ }
+
/// Devuelve el valor del atributo indicado, si existe.
///
- /// Los nombres `"id"` y `"class"` son equivalentes a llamar a [`get_id()`](Self::get_id) y
- /// [`get_classes()`](Self::get_classes) respectivamente.
+ /// Los nombres `"id"`, `"class"` y `"style"` son equivalentes a llamar a
+ /// [`get_id()`](Self::get_id), [`get_classes()`](Self::get_classes) y
+ /// [`get_styles()`](Self::get_styles) respectivamente.
pub fn get_prop(&self, name: impl AsRef) -> Option {
match name.as_ref() {
"id" => self.id.clone(),
"class" => self.get_classes(),
+ "style" => self.get_styles(),
name => self
.attrs
.iter()
@@ -525,18 +610,27 @@ impl Props {
self.classes.is_empty()
}
+ /// Devuelve `true` si no hay ningún estilo definido.
+ #[inline]
+ pub fn is_styles_empty(&self) -> bool {
+ self.styles.is_empty()
+ }
+
/// Devuelve `true` si no hay ningún atributo adicional definido, sin tener en cuenta el
- /// identificador ni las clases.
+ /// identificador, las clases ni los estilos.
#[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.
+ /// Devuelve `true` si no hay ningún identificador, clases, estilos o atributos adicionales
+ /// definidos, sin tener en cuenta los valores extra.
#[inline]
pub fn is_empty(&self) -> bool {
- self.id.is_none() && self.attrs.is_empty() && self.classes.is_empty()
+ self.id.is_none()
+ && self.classes.is_empty()
+ && self.styles.is_empty()
+ && self.attrs.is_empty()
}
/// Devuelve `true` si la clase o **todas** las clases indicadas están presentes.
@@ -578,7 +672,10 @@ impl Props {
/// let props = Props::default().with_prop(PropsOp::set_extra(EXT_COUNT, 7_i32));
///
/// assert_eq!(*props.extra::(EXT_COUNT).unwrap(), 7);
- /// assert_eq!(props.extra::(EXT_OTHER), Err(PropsError::ExtraNotFound { key: EXT_OTHER }));
+ /// assert_eq!(
+ /// props.extra::(EXT_OTHER),
+ /// Err(PropsError::ExtraNotFound { key: EXT_OTHER })
+ /// );
/// assert!(matches!(
/// props.extra::(EXT_COUNT),
/// Err(PropsError::ExtraTypeMismatch { .. })
@@ -678,6 +775,79 @@ impl Props {
}
}
}
+
+ // Añade o sustituye una declaración "propiedad: valor". Si la propiedad ya existe, sustituye
+ // su valor conservando la posición; si no, la añade al final. Ignora la declaración si la
+ // propiedad o el valor quedan vacíos tras recortar espacios. No aplica
+ // normalize_ascii_or_empty: ver la documentación de `PropsOp::AddStyle` sobre por qué los
+ // valores de estilo no se restringen a ASCII.
+ fn set_style(&mut self, property: &str, value: &str) {
+ let property = property.trim().to_ascii_lowercase();
+ let value = value.trim();
+ if property.is_empty() || value.is_empty() {
+ return;
+ }
+ if let Some(pos) = self.styles.iter().position(|(k, _)| k.as_ref() == property) {
+ self.styles[pos].1 = value.to_string().into();
+ } else {
+ self.styles
+ .push((property.into(), value.to_string().into()));
+ }
+ }
+
+ // Interpreta una cadena "propiedad: valor" separadas por ";" (igual que el atributo HTML
+ // `style`) y aplica cada declaración con `set_style`. Ignora las declaraciones sin ":".
+ fn parse_styles(&mut self, styles: &str) {
+ for style in Self::split_style_declarations(styles) {
+ let style = style.trim();
+ if style.is_empty() {
+ continue;
+ }
+ let Some((property, value)) = style.split_once(':') else {
+ trace::debug!(
+ target = "Props::with_prop",
+ declaration = %style,
+ "Ignoring malformed style declaration (missing \":\")"
+ );
+ continue;
+ };
+ self.set_style(property, value);
+ }
+ }
+
+ // Divide una cadena de declaraciones de estilo por ";", igual que `str::split(';')`, pero sin
+ // cortar dentro de paréntesis (`url(...)`) ni de cadenas entre comillas simples o dobles
+ // (`content: "a;b"`). No es un análisis CSS completo: no reconoce comentarios `/* ... */` ni
+ // comillas escapadas, y unos paréntesis o comillas sin cerrar arrastran el resto de la cadena
+ // a la última declaración.
+ fn split_style_declarations(styles: &str) -> Vec<&str> {
+ let mut depth = 0i32;
+ let mut quote = None;
+ let mut start = 0;
+ let mut parts = Vec::new();
+ for (i, c) in styles.char_indices() {
+ if quote.is_none() && (c == '\'' || c == '"') {
+ quote = Some(c);
+ } else if quote == Some(c) {
+ quote = None;
+ } else if quote.is_none() && c == '(' {
+ depth += 1;
+ } else if quote.is_none() && c == ')' {
+ depth = (depth - 1).max(0);
+ } else if quote.is_none() && depth == 0 && c == ';' {
+ parts.push(&styles[start..i]);
+ start = i + 1;
+ }
+ }
+ parts.push(&styles[start..]);
+ parts
+ }
+
+ // Elimina la propiedad de estilo indicada, si existe.
+ fn remove_style(&mut self, property: &str) {
+ let property = property.trim().to_ascii_lowercase();
+ self.styles.retain(|(k, _)| k.as_ref() != property);
+ }
}
#[doc(hidden)]
@@ -697,6 +867,15 @@ impl Render for Props {
}
w.push('"');
}
+ if let Some((first, rest)) = self.styles.split_first() {
+ w.push_str(" style=\"");
+ let _ = write!(Escaper::new(w), "{}: {}", first.0, first.1);
+ for (property, value) in rest {
+ w.push_str("; ");
+ let _ = write!(Escaper::new(w), "{}: {}", property, value);
+ }
+ w.push('"');
+ }
for (name, value) in &self.attrs {
w.push(' ');
let _ = write!(Escaper::new(w), "{}", name);
diff --git a/tests/html_props_styles.rs b/tests/html_props_styles.rs
new file mode 100644
index 00000000..821dfc22
--- /dev/null
+++ b/tests/html_props_styles.rs
@@ -0,0 +1,317 @@
+use pagetop::prelude::*;
+
+fn assert_styles(p: &Props, expected: Option<&str>) {
+ let got = p.get_styles();
+ assert_eq!(
+ got.as_deref(),
+ expected,
+ "Expected {:?}, got {:?}",
+ expected,
+ got
+ );
+}
+
+// **< PropsOp::add_style >*************************************************************************
+
+#[pagetop::test]
+async fn add_style_basic_adds_declaration() {
+ let p = Props::default().with_prop(PropsOp::add_style("color", "red"));
+ assert_styles(&p, Some("color: red"));
+}
+
+#[pagetop::test]
+async fn add_style_multiple_calls_accumulate_in_order() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::add_style("font-weight", "bold"));
+ assert_styles(&p, Some("color: red; font-weight: bold"));
+}
+
+#[pagetop::test]
+async fn add_style_overrides_existing_property_preserving_position() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::add_style("font-weight", "bold"))
+ .with_prop(PropsOp::add_style("color", "blue"));
+ assert_styles(&p, Some("color: blue; font-weight: bold"));
+}
+
+#[pagetop::test]
+async fn add_style_property_name_is_case_insensitive() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("Color", "red"))
+ .with_prop(PropsOp::add_style("COLOR", "blue"));
+ assert_styles(&p, Some("color: blue"));
+}
+
+#[pagetop::test]
+async fn add_style_value_preserves_case_and_non_ascii() {
+ let p = Props::default().with_prop(PropsOp::add_style("font-family", "'Alegreya Sans', ARIAL"));
+ assert_styles(&p, Some("font-family: 'Alegreya Sans', ARIAL"));
+
+ let p = Props::default().with_prop(PropsOp::add_style("content", "'Ñoño'"));
+ assert_styles(&p, Some("content: 'Ñoño'"));
+}
+
+#[pagetop::test]
+async fn add_style_value_may_contain_semicolons() {
+ // A diferencia de PropsOp::Set("style", ...), que interpreta la cadena como declaraciones
+ // separadas por ";", AddStyle recibe la propiedad y el valor ya separados, así que un ";"
+ // dentro del valor (p. ej. una data URI) no supone ningún problema.
+ let p = Props::default().with_prop(PropsOp::add_style(
+ "background",
+ "url(data:image/png;base64,AAAA)",
+ ));
+ assert_styles(&p, Some("background: url(data:image/png;base64,AAAA)"));
+}
+
+#[pagetop::test]
+async fn add_style_trims_whitespace() {
+ let p = Props::default().with_prop(PropsOp::add_style(" color ", " red "));
+ assert_styles(&p, Some("color: red"));
+}
+
+#[pagetop::test]
+async fn add_style_ignores_empty_property_or_value() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("", "red"))
+ .with_prop(PropsOp::add_style("color", ""))
+ .with_prop(PropsOp::add_style(" ", " "));
+ assert_styles(&p, None);
+}
+
+// **< PropsOp::remove_style >**********************************************************************
+
+#[pagetop::test]
+async fn remove_style_removes_indicated_property() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::add_style("font-weight", "bold"))
+ .with_prop(PropsOp::remove_style("font-weight"));
+ assert_styles(&p, Some("color: red"));
+}
+
+#[pagetop::test]
+async fn remove_style_is_case_insensitive() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::remove_style("COLOR"));
+ assert_styles(&p, None);
+}
+
+#[pagetop::test]
+async fn remove_style_trims_whitespace() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::remove_style(" color "));
+ assert_styles(&p, None);
+}
+
+#[pagetop::test]
+async fn remove_style_non_existing_is_noop() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::remove_style("font-weight"));
+ assert_styles(&p, Some("color: red"));
+}
+
+// **< PropsOp::set / remove ("style") >************************************************************
+
+#[pagetop::test]
+async fn styles_reset_replaces_entire_list() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::add_style("font-weight", "bold"))
+ .with_prop(PropsOp::set("style", "margin: 0"));
+ assert_styles(&p, Some("margin: 0"));
+}
+
+#[pagetop::test]
+async fn styles_reset_parses_multiple_declarations() {
+ let p = Props::default().with_prop(PropsOp::set("style", "color: red; font-weight: bold"));
+ assert_styles(&p, Some("color: red; font-weight: bold"));
+}
+
+#[pagetop::test]
+async fn styles_reset_ignores_declaration_without_colon() {
+ let p = Props::default().with_prop(PropsOp::set(
+ "style",
+ "color: red; not-a-declaration; font-weight: bold",
+ ));
+ assert_styles(&p, Some("color: red; font-weight: bold"));
+}
+
+#[pagetop::test]
+async fn styles_reset_ignores_empty_property_or_value() {
+ let p = Props::default().with_prop(PropsOp::set("style", ": red; color: ; ok: yes"));
+ assert_styles(&p, Some("ok: yes"));
+}
+
+#[pagetop::test]
+async fn styles_reset_trims_whitespace_around_declarations() {
+ let p = Props::default().with_prop(PropsOp::set(
+ "style",
+ " color : red ; font-weight:bold ",
+ ));
+ assert_styles(&p, Some("color: red; font-weight: bold"));
+}
+
+#[pagetop::test]
+async fn styles_reset_with_empty_input_clears() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::set("style", ""));
+ assert_styles(&p, None);
+}
+
+#[pagetop::test]
+async fn styles_reset_does_not_split_semicolon_inside_parens() {
+ let p = Props::default().with_prop(PropsOp::set(
+ "style",
+ "background: url(data:image/png;base64,AAAA)",
+ ));
+ assert_styles(&p, Some("background: url(data:image/png;base64,AAAA)"));
+}
+
+#[pagetop::test]
+async fn styles_reset_does_not_split_semicolon_inside_quotes() {
+ let p = Props::default().with_prop(PropsOp::set("style", r#"content: "a;b""#));
+ assert_styles(&p, Some(r#"content: "a;b""#));
+}
+
+#[pagetop::test]
+async fn styles_reset_mixes_declarations_with_and_without_parens() {
+ let p = Props::default().with_prop(PropsOp::set(
+ "style",
+ "color: red; background: url(a;b); margin: 0",
+ ));
+ assert_styles(&p, Some("color: red; background: url(a;b); margin: 0"));
+}
+
+// Límite conocido de PropsOp::Set("style", ...): no es un análisis CSS completo. Unos paréntesis
+// sin cerrar arrastran el resto de la cadena a la misma declaración. Este test fija el
+// comportamiento actual para que un cambio futuro sea deliberado, no accidental.
+#[pagetop::test]
+async fn styles_reset_unbalanced_parens_swallows_rest_of_string() {
+ let p = Props::default().with_prop(PropsOp::set(
+ "style",
+ "background: url(data:image/png; margin: 0",
+ ));
+ assert_styles(&p, Some("background: url(data:image/png; margin: 0"));
+}
+
+#[pagetop::test]
+async fn styles_remove_attr_clears_all_styles() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::remove("style"));
+ assert_styles(&p, None);
+}
+
+// **< is_styles_empty >****************************************************************************
+
+#[pagetop::test]
+async fn props_is_styles_empty_on_default() {
+ assert!(Props::default().is_styles_empty());
+}
+
+#[pagetop::test]
+async fn props_is_styles_empty_false_after_add_style() {
+ let p = Props::default().with_prop(PropsOp::add_style("color", "red"));
+ assert!(!p.is_styles_empty());
+}
+
+#[pagetop::test]
+async fn props_is_styles_empty_true_after_remove_style() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::remove("style"));
+ assert!(p.is_styles_empty());
+}
+
+// **< get_styles / get_style / get_prop("style") >*************************************************
+
+#[pagetop::test]
+async fn styles_get_returns_none_when_empty_some_when_not() {
+ assert_styles(&Props::default(), None);
+ let p = Props::default().with_prop(PropsOp::add_style("color", "red"));
+ assert_styles(&p, Some("color: red"));
+}
+
+#[pagetop::test]
+async fn get_style_returns_value_for_existing_property() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::add_style("font-weight", "bold"));
+ assert_eq!(p.get_style("color"), Some("red".to_string()));
+ assert_eq!(p.get_style("font-weight"), Some("bold".to_string()));
+}
+
+#[pagetop::test]
+async fn get_style_is_case_insensitive_and_trims_input() {
+ let p = Props::default().with_prop(PropsOp::add_style("color", "red"));
+ assert_eq!(p.get_style("COLOR"), Some("red".to_string()));
+ assert_eq!(p.get_style(" Color "), Some("red".to_string()));
+}
+
+#[pagetop::test]
+async fn get_style_returns_none_for_missing_property() {
+ let p = Props::default().with_prop(PropsOp::add_style("color", "red"));
+ assert_eq!(p.get_style("margin"), None);
+}
+
+#[pagetop::test]
+async fn get_prop_style_matches_get_styles() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::add_style("font-weight", "bold"));
+ assert_eq!(p.get_prop("style"), p.get_styles());
+}
+
+// **< HTML rendering >*****************************************************************************
+
+#[pagetop::test]
+async fn props_styles_renders_style_attribute() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::add_style("font-weight", "bold"));
+ assert_eq!(
+ html! { button (p) { "OK" } }.into_string(),
+ r#""#
+ );
+}
+
+#[pagetop::test]
+async fn props_styles_render_after_class_and_before_other_attrs() {
+ let p = Props::default()
+ .with_id("main")
+ .with_prop(PropsOp::add_classes("btn"))
+ .with_prop(PropsOp::add_style("color", "red"))
+ .with_prop(PropsOp::set("data-x", "1"));
+ assert_eq!(
+ html! { button (p) { "OK" } }.into_string(),
+ r#""#
+ );
+}
+
+#[pagetop::test]
+async fn props_styles_escapes_double_quotes_in_value() {
+ let p = Props::default().with_prop(PropsOp::add_style("content", r#""hi""#));
+ assert_eq!(
+ html! { span (p) {} }.into_string(),
+ r#""#
+ );
+}
+
+// **< Combined sequences >*************************************************************************
+
+#[pagetop::test]
+async fn styles_sequence_preserves_position_through_updates_and_removals() {
+ let p = Props::default()
+ .with_prop(PropsOp::add_style("color", "red")) // color: red
+ .with_prop(PropsOp::add_style("font-weight", "bold")) // color: red; font-weight: bold
+ .with_prop(PropsOp::add_style("margin", "0")) // color: red; font-weight: bold; margin: 0
+ .with_prop(PropsOp::remove_style("font-weight")) // color: red; margin: 0
+ .with_prop(PropsOp::add_style("color", "blue")); // color: blue; margin: 0
+ assert_styles(&p, Some("color: blue; margin: 0"));
+}