use crate::{AutoDefault, CowStr, 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()`]. #[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, /// Sustituye una o varias clases existentes (indicadas en la variante) por las clases /// proporcionadas. Replace(CowStr), /// 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. Set, } /// 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 /// /// - 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. /// - 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::Replace("active".into()), "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::Replace(classes_to_replace) => { let Some(classes_to_replace) = util::normalize_ascii_or_empty( classes_to_replace.as_ref(), "ClassesOp::Replace", ) else { return self; }; let mut pos = self.0.len(); let mut replaced = false; for class in classes_to_replace.as_ref().split_ascii_whitespace() { if let Some(replace_pos) = self.0.iter().position(|c| c == class) { self.0.remove(replace_pos); pos = pos.min(replace_pos); replaced = true; } } if replaced { self.add(normalized.as_ref().split_ascii_whitespace(), pos); } } 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::Set => { 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 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)) } }