diff --git a/src/html/classes.rs b/src/html/classes.rs index 0123d383..2fd2fbf4 100644 --- a/src/html/classes.rs +++ b/src/html/classes.rs @@ -3,44 +3,33 @@ use crate::{builder_fn, AutoDefault}; use std::borrow::Cow; /// 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()`]. pub enum ClassesOp { - /// Añade las clases que no existan al final. + /// Añade al final (si no existe). Add, - /// Añade las clases que no existan al principio. + /// Añade al principio. Prepend, - /// Elimina las clases indicadas que existan. + /// Elimina la(s) clase(s) indicada(s). Remove, - /// Sustituye una o varias clases existentes (indicadas en la variante) por las clases - /// proporcionadas. + /// Sustituye una o varias clases por otras nuevas (`Replace("old other".into())`). Replace(Cow<'static, str>), /// 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. + /// Sustituye toda la lista. Set, } -/// Lista de clases CSS normalizadas para el atributo `class` de HTML. +/// Cadena 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 /// -/// - Aunque el orden de las clases en el atributo `class` no afecta al resultado en CSS, -/// [`ClassesOp`] ofrece operaciones para controlar su orden de aparición por legibilidad. -/// - 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). +/// - El [orden de las clases no es relevante](https://stackoverflow.com/a/1321712) en CSS, pero +/// [`ClassesOp`] ofrece operaciones para controlar su orden de aparición. +/// - Las clases se convierten a minúsculas. +/// - No se permiten clases duplicadas. /// - Las clases vacías se ignoran. -/// - Sin clases, [`get()`](Self::get) devuelve `None` (no `Some("")`). /// /// # Ejemplo /// @@ -70,62 +59,53 @@ impl Classes { /// lista de clases actual. #[builder_fn] pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { - let classes = classes.as_ref(); + let classes = classes.as_ref().to_ascii_lowercase(); + let classes: Vec<&str> = classes.split_ascii_whitespace().collect(); + + if classes.is_empty() { + return self; + } + match op { ClassesOp::Add => { - self.add(classes, self.0.len()); + self.add(&classes, self.0.len()); } ClassesOp::Prepend => { - self.add(classes, 0); + self.add(&classes, 0); } ClassesOp::Remove => { - let mut classes_to_remove = classes.split_ascii_whitespace(); - - // 0 clases: no se hace nada. - let Some(first) = classes_to_remove.next() else { - return self; - }; - - // 1 clase: un único *retain*, cero reservas extra. - let first = first.to_ascii_lowercase(); - let Some(second) = classes_to_remove.next() else { - self.0.retain(|c| c != &first); - return self; - }; - - // 2+ clases: se construye lista para borrar y un único *retain*. - let mut to_remove = Vec::new(); - to_remove.push(first); - to_remove.push(second.to_ascii_lowercase()); - for class in classes_to_remove { - to_remove.push(class.to_ascii_lowercase()); + for class in classes { + self.0.retain(|c| c != class); } - self.0.retain(|c| !to_remove.iter().any(|r| r == c)); } ClassesOp::Replace(classes_to_replace) => { let mut pos = self.0.len(); - for class in classes_to_replace.split_ascii_whitespace() { - let class = class.to_ascii_lowercase(); - if let Some(replace_pos) = self.0.iter().position(|c| c == &class) { + let replace = classes_to_replace.to_ascii_lowercase(); + let replace: Vec<&str> = replace.split_ascii_whitespace().collect(); + for class in replace { + if let Some(replace_pos) = self.0.iter().position(|c| c == class) { self.0.remove(replace_pos); - pos = pos.min(replace_pos); + if pos > replace_pos { + pos = replace_pos; + } } } - self.add(classes, pos); + self.add(&classes, pos); } ClassesOp::Toggle => { - for class in classes.split_ascii_whitespace() { - let class = class.to_ascii_lowercase(); - if let Some(pos) = self.0.iter().position(|c| c == &class) { - self.0.remove(pos); - } else { - self.0.push(class); + for class in classes { + if !class.is_empty() { + if let Some(pos) = self.0.iter().position(|c| c.eq(class)) { + self.0.remove(pos); + } else { + self.0.push(class.to_string()); + } } } } ClassesOp::Set => { self.0.clear(); - self.add(classes, 0); + self.add(&classes, 0); } } @@ -133,16 +113,10 @@ impl Classes { } #[inline] - fn add(&mut self, classes: &str, mut pos: usize) { - for class in classes.split_ascii_whitespace() { - let class = class.to_ascii_lowercase(); - // Inserción segura descartando duplicados. - if !self.0.iter().any(|c| c == &class) { - if pos >= self.0.len() { - self.0.push(class); - } else { - self.0.insert(pos, class); - } + fn add(&mut self, classes: &[&str], mut pos: usize) { + for &class in classes { + if !class.is_empty() && !self.0.iter().any(|c| c == class) { + self.0.insert(pos, class.to_string()); pos += 1; } } @@ -159,32 +133,9 @@ impl Classes { } } - /// Devuelve `true` si **una única clase** está presente. - /// - /// Si necesitas comprobar varias clases, usa [`contains_all()`](Self::contains_all) o - /// [`contains_any()`](Self::contains_any). + /// Devuelve `true` si la clase está presente. pub fn contains(&self, class: impl AsRef) -> bool { - self.contains_class(class.as_ref()) - } - - /// Devuelve `true` si **todas** las clases indicadas están presentes. - pub fn contains_all(&self, classes: impl AsRef) -> bool { - classes - .as_ref() - .split_ascii_whitespace() - .all(|class| self.contains_class(class)) - } - - /// Devuelve `true` si **alguna** de las clases indicadas está presente. - pub fn contains_any(&self, classes: impl AsRef) -> bool { - classes - .as_ref() - .split_ascii_whitespace() - .any(|class| self.contains_class(class)) - } - - #[inline] - fn contains_class(&self, class: &str) -> bool { - self.0.iter().any(|c| c.eq_ignore_ascii_case(class)) + let class = class.as_ref().to_ascii_lowercase(); + self.0.iter().any(|c| c == &class) } } diff --git a/tests/html_classes.rs b/tests/html_classes.rs deleted file mode 100644 index 00de96c5..00000000 --- a/tests/html_classes.rs +++ /dev/null @@ -1,199 +0,0 @@ -use pagetop::prelude::*; - -fn assert_classes(c: &Classes, expected: Option<&str>) { - let got = c.get(); - assert_eq!(got.as_deref(), expected, "Expected {:?}, got {:?}", expected, got); -} - -// **< Construction & invariants (new/get) >******************************************************** - -#[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); -} - -#[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")); -} - -#[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")); -} - -// **< 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")); -} - -#[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")); -} - -#[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")); -} - -#[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")); -} - -#[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")); -} - -#[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")); -} - -#[pagetop::test] -async fn classes_set_replaces_entire_list_and_dedups() { - let c = Classes::new("a b c").with_classes(ClassesOp::Set, "X y y Z"); - assert_classes(&c, Some("x y z")); -} - -#[pagetop::test] -async fn classes_set_with_empty_input_clears() { - let base = Classes::new("a b"); - let c = base.with_classes(ClassesOp::Set, " \n "); - assert_classes(&c, None); -} - -// **< Mutation operations (remove/toggle/replace) >************************************************ - -#[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")); -} - -#[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")); -} - -#[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")); -} - -#[pagetop::test] -async fn classes_replace_removes_targets_and_inserts_new_at_min_position() { - let c = Classes::new("a b c d").with_classes(ClassesOp::Replace("c a".into()), "x y"); - assert_classes(&c, Some("x y b d")); -} - -#[pagetop::test] -async fn classes_replace_when_none_found_appends_at_end() { - let c = Classes::new("a b").with_classes(ClassesOp::Replace("x y".into()), "c d"); - assert_classes(&c, Some("a b c d")); -} - -#[pagetop::test] -async fn classes_replace_is_case_insensitive_on_targets_and_new_values_are_normalized() { - let c = Classes::new("btn btn-primary active") - .with_classes(ClassesOp::Replace("BTN-PRIMARY".into()), "Btn-Secondary"); - assert_classes(&c, Some("btn btn-secondary active")); -} - -#[pagetop::test] -async fn classes_replace_with_empty_new_removes_only() { - let c = Classes::new("a b c").with_classes(ClassesOp::Replace("b".into()), " "); - assert_classes(&c, Some("a c")); -} - -#[pagetop::test] -async fn classes_replace_dedups_against_existing_items() { - let c = Classes::new("a b c").with_classes(ClassesOp::Replace("b".into()), "c d"); - assert_classes(&c, Some("a d c")); -} - -#[pagetop::test] -async fn classes_replace_ignores_target_whitespace_and_repetition() { - let c = Classes::new("a b c").with_classes(ClassesOp::Replace(" b b ".into()), "x y"); - assert_classes(&c, Some("a x y 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")); -} - -#[pagetop::test] -async fn classes_contains_all_and_any() { - let c = Classes::new("btn btn-primary active"); - - assert!(c.contains_all("btn active")); - assert!(c.contains_all("BTN BTN-PRIMARY")); - assert!(!c.contains_all("btn missing")); - - assert!(c.contains_any("missing active")); - assert!(c.contains_any("BTN-PRIMARY missing")); - assert!(!c.contains_any("missing other")); -} - -// **< 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")); -}