(props): Añade ReplaceAllClasses

Sustituye clases sólo si están todas las indicadas en el primer valor; a
diferencia de `ReplaceClasses`, no actúa parcialmente si falta alguna.
Renombra `Props::has_class()`/`has_any_class()` a `has_all_classes()`/
`has_classes()`, que la nueva variante usa internamente para comprobar
la condición.
This commit is contained in:
Manuel Cillero 2026-08-16 13:58:47 +02:00
parent 946d335664
commit ed30b2ae17
2 changed files with 137 additions and 34 deletions

View file

@ -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<CowStr>, new: impl Into<CowStr>) -> 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<CowStr>, new: impl Into<CowStr>) -> 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<CowStr>) -> 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<str>) -> 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<str>) -> bool {
pub fn has_classes(&self, classes: impl AsRef<str>) -> 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<str>) -> 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:

View file

@ -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 >***************************************************************************