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"));
-}