diff --git a/src/html/props.rs b/src/html/props.rs index d120282f..616b3cf7 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -99,11 +99,19 @@ pub enum PropsOp { /// Añade la clase o clases que no existan al principio de la lista. La operación se ignora si /// el valor contiene caracteres no ASCII. PrependClasses(CowStr), - /// Sustituye una o varias clases existentes (primer valor) por las clases indicadas (segundo - /// valor), insertando las nuevas en la posición de la primera clase sustituida encontrada. Si - /// ninguna de las clases a sustituir existe, la operación no tiene efecto. Se ignora si alguno - /// de los dos valores contiene caracteres no ASCII. + /// Sustituye **una o más** clases del primer valor por las clases indicadas en el segundo + /// valor, insertando las nuevas en la posición de la primera clase a sustituir encontrada, con + /// independencia del orden en que aparecen en el primer valor. Las que no existan se ignoran. + /// Si **ninguna** de las clases a sustituir existe, la operación no tiene efecto y no se + /// inserta nada. Se ignora si alguno de los dos valores contiene caracteres no ASCII. ReplaceClasses(CowStr, CowStr), + /// A diferencia de [`ReplaceClasses`](Self::ReplaceClasses), exige que **todas** las clases del + /// primer valor estén presentes, independientemente de su orden; si falta una sola, la + /// operación no tiene efecto: ninguna clase se elimina ni se inserta. Si todas están presentes, + /// las sustituye por las clases indicadas en el segundo valor, insertando las nuevas en la + /// posición de la primera clase a sustituir encontrada. Se ignora si alguno de los dos valores + /// contiene caracteres no ASCII. + ReplaceAllClasses(CowStr, CowStr), /// Elimina la clase o clases indicadas de la lista. La operación se ignora si el valor contiene /// caracteres no ASCII. RemoveClasses(CowStr), @@ -169,11 +177,34 @@ impl PropsOp { /// let props = Props::classes("button primary") /// .with_prop(PropsOp::replace_classes("button", "btn")); /// assert_eq!(props.get_classes(), Some("btn primary".to_string())); + /// + /// // Basta con que exista alguna clase de `old` para aplicar el reemplazo. + /// let props = Props::classes("btn primary") + /// .with_prop(PropsOp::replace_classes("primary secondary", "danger")); + /// assert_eq!(props.get_classes(), Some("btn danger".to_string())); /// ``` pub fn replace_classes(old: impl Into, new: impl Into) -> Self { Self::ReplaceClasses(old.into(), new.into()) } + /// Crea la variante [`ReplaceAllClasses`](Self::ReplaceAllClasses) con las clases a sustituir + /// (`old`) y las nuevas clases (`new`). + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let props = Props::classes("btn primary") + /// .with_prop(PropsOp::replace_all_classes("btn primary", "btn danger")); + /// assert_eq!(props.get_classes(), Some("btn danger".to_string())); + /// + /// // Si falta una sola clase de `old`, no hay reemplazo. + /// let props = Props::classes("btn primary") + /// .with_prop(PropsOp::replace_all_classes("primary secondary", "danger")); + /// assert_eq!(props.get_classes(), Some("btn primary".to_string())); + /// ``` + pub fn replace_all_classes(old: impl Into, new: impl Into) -> Self { + Self::ReplaceAllClasses(old.into(), new.into()) + } + /// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con la clase o clases indicadas. pub fn remove_classes(classes: impl Into) -> Self { Self::RemoveClasses(classes.into()) @@ -462,6 +493,27 @@ impl Props { self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); } } + PropsOp::ReplaceAllClasses(old, new) => { + let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop") + else { + return self; + }; + let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop") + else { + return self; + }; + if !self.has_all_classes(old.as_ref()) { + return self; + } + let mut pos = self.classes.len(); + for class in old.as_ref().split_ascii_whitespace() { + if let Some(replace_pos) = self.classes.iter().position(|c| c == class) { + self.classes.remove(replace_pos); + pos = pos.min(replace_pos); + } + } + self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); + } PropsOp::RemoveClasses(classes) => { let Some(normalized) = util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") @@ -616,19 +668,8 @@ impl Props { && self.attrs.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 { + pub fn has_classes(&self, classes: impl AsRef) -> bool { let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { return false; }; @@ -638,6 +679,17 @@ impl Props { .any(|class| self.classes.iter().any(|c| c == class)) } + /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. + pub fn has_all_classes(&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)) + } + /// Recupera una referencia tipada al valor extra asociado a la clave `key`. /// /// Devuelve un [`Result`] que indica si la clave existe y si el tipo coincide: diff --git a/tests/html_props_classes.rs b/tests/html_props_classes.rs index ededb8b2..cf06484a 100644 --- a/tests/html_props_classes.rs +++ b/tests/html_props_classes.rs @@ -24,8 +24,8 @@ async fn classes_new_empty_and_whitespace_is_empty() { async fn classes_new_normalizes_and_dedups_and_preserves_first_occurrence_order() { 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")); + assert!(p.has_all_classes("BTN")); + assert!(p.has_all_classes("btn-primary")); } // **< PropsOp::add_classes >*********************************************************************** @@ -119,6 +119,57 @@ async fn classes_replace_rejects_non_ascii_targets_is_noop() { assert_classes(&p, Some("a b c")); } +// **< PropsOp::replace_all_classes >*************************************************************** + +#[pagetop::test] +async fn classes_replace_all_removes_targets_and_inserts_new_at_min_position() { + let p = Props::classes("a b c d").with_prop(PropsOp::replace_all_classes("c a", "x y")); + assert_classes(&p, Some("x y b d")); +} + +#[pagetop::test] +async fn classes_replace_all_when_missing_one_does_nothing_even_to_existing_one() { + let p = Props::classes("a b").with_prop(PropsOp::replace_all_classes("a x", "c d")); + assert_classes(&p, Some("a b")); +} + +#[pagetop::test] +async fn classes_replace_all_when_none_found_does_nothing() { + let p = Props::classes("a b").with_prop(PropsOp::replace_all_classes("x y", "c d")); + assert_classes(&p, Some("a b")); +} + +#[pagetop::test] +async fn classes_replace_all_is_case_insensitive_on_targets_and_new_values_are_normalized() { + let p = Props::classes("btn btn-primary active") + .with_prop(PropsOp::replace_all_classes("BTN ACTIVE", "Btn-Secondary")); + assert_classes(&p, Some("btn-secondary btn-primary")); +} + +#[pagetop::test] +async fn classes_replace_all_with_empty_new_removes_only() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("a b", " ")); + assert_classes(&p, Some("c")); +} + +#[pagetop::test] +async fn classes_replace_all_dedups_against_existing_items() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("a b", "c d")); + assert_classes(&p, Some("d c")); +} + +#[pagetop::test] +async fn classes_replace_all_ignores_target_whitespace_and_repetition() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes(" b b ", "x y")); + assert_classes(&p, Some("a x y c")); +} + +#[pagetop::test] +async fn classes_replace_all_rejects_non_ascii_targets_is_noop() { + let p = Props::classes("a b c").with_prop(PropsOp::replace_all_classes("b ñ", "x")); + assert_classes(&p, Some("a b c")); +} + // **< PropsOp::set / remove ("class") >************************************************************ #[pagetop::test] @@ -165,41 +216,41 @@ async fn classes_remove_with_extra_whitespace() { assert_classes(&p, Some("a c")); } -// **< has_class / has_any_class >****************************************************************** +// **< has_classes / has_all_classes >************************************************************** #[pagetop::test] async fn classes_contains_single() { let p = Props::classes("btn btn-primary"); - assert!(p.has_class("btn")); - assert!(p.has_class("BTN")); - assert!(!p.has_class("missing")); + assert!(p.has_all_classes("btn")); + assert!(p.has_all_classes("BTN")); + assert!(!p.has_all_classes("missing")); } #[pagetop::test] async fn classes_contains_all_and_any() { let p = Props::classes("btn btn-primary active"); - assert!(p.has_class("btn active")); - assert!(p.has_class("BTN BTN-PRIMARY")); - assert!(!p.has_class("btn missing")); - assert!(p.has_any_class("missing active")); - assert!(p.has_any_class("BTN-PRIMARY missing")); - assert!(!p.has_any_class("missing other")); + assert!(p.has_classes("missing active")); + assert!(p.has_classes("BTN-PRIMARY missing")); + assert!(!p.has_classes("missing other")); + assert!(p.has_all_classes("btn active")); + assert!(p.has_all_classes("BTN BTN-PRIMARY")); + assert!(!p.has_all_classes("btn missing")); } #[pagetop::test] async fn classes_contains_empty_and_whitespace_is_false() { 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 ")); + assert!(!p.has_classes("")); + assert!(!p.has_classes(" \n ")); + assert!(!p.has_all_classes("")); + assert!(!p.has_all_classes(" \t")); } #[pagetop::test] async fn classes_contains_non_ascii_is_false() { let p = Props::classes("a b"); - assert!(!p.has_class("ñ")); - assert!(!p.has_any_class("a ñ")); + assert!(!p.has_classes("a ñ")); + assert!(!p.has_all_classes("ñ")); } // **< is_classes_empty >***************************************************************************