(html): Añade PropsOp::replace_classes()

Permite sustituir una o varias clases por otras nuevas en una sola
operación, preservando su posición. Adapta el tema Bootsier para usarla
en `Button` y elimina el handler duplicado sin referencias.
This commit is contained in:
Manuel Cillero 2026-07-09 01:12:45 +02:00
parent 9e8cd77140
commit bff7cf1b38
4 changed files with 98 additions and 9 deletions

View file

@ -1,7 +0,0 @@
use pagetop::prelude::*;
pub fn setup(button: &mut Button) {
button
.alter_prop(PropsOp::remove_classes("button"))
.alter_prop(PropsOp::prepend_classes("btn"));
}

View file

@ -1 +1,9 @@
use pagetop::prelude::*;
pub use pagetop::base::component::{Button, ButtonAction};
// **< Button SETUP >*******************************************************************************
pub(crate) fn setup(button: &mut Button) {
button.alter_prop(PropsOp::replace_classes("button", "btn"));
}

View file

@ -112,6 +112,11 @@ 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.
ReplaceClasses(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),
@ -151,6 +156,19 @@ impl PropsOp {
Self::PrependClasses(classes.into())
}
/// Crea la variante [`ReplaceClasses`](Self::ReplaceClasses) con las clases a sustituir (`old`)
/// y las nuevas clases (`new`).
///
/// ```rust
/// # use pagetop::prelude::*;
/// let props = Props::classes("button primary")
/// .with_prop(PropsOp::replace_classes("button", "btn"));
/// assert_eq!(props.get_classes(), Some("btn primary".to_string()));
/// ```
pub fn replace_classes(old: impl Into<CowStr>, new: impl Into<CowStr>) -> Self {
Self::ReplaceClasses(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())
@ -251,10 +269,11 @@ impl PropsOp {
/// # use pagetop::prelude::*;
/// let props = Props::default()
/// .with_prop(PropsOp::add_classes("btn btn-primary"))
/// .with_prop(PropsOp::add_classes("active"));
/// .with_prop(PropsOp::add_classes("active"))
/// .with_prop(PropsOp::replace_classes("btn-primary", "btn-secondary"));
///
/// let markup = html! { button (props) { "OK" } };
/// assert_eq!(markup.into_string(), r#"<button class="btn btn-primary active">OK</button>"#);
/// assert_eq!(markup.into_string(), r#"<button class="btn btn-secondary active">OK</button>"#);
/// ```
///
/// # Valores extra
@ -354,6 +373,8 @@ impl Props {
/// - [`PropsOp::ensure_id()`] - establece el identificador sólo si no hay ninguno definido.
/// - [`PropsOp::add_classes()`] - añade clases al final (sin duplicados).
/// - [`PropsOp::prepend_classes()`] - añade clases al principio (sin duplicados).
/// - [`PropsOp::replace_classes()`] - sustituye una o varias clases existentes por otras
/// nuevas, preservando su posición.
/// - [`PropsOp::remove_classes()`] - elimina las clases indicadas.
/// - [`PropsOp::set()`] - añade el atributo o reemplaza su valor. `set("id", ...)` aplica la
/// misma normalización que `set_id()`. `set("class", ...)` reemplaza la lista de clases.
@ -389,6 +410,28 @@ impl Props {
};
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0);
}
PropsOp::ReplaceClasses(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;
};
let mut pos = self.classes.len();
let mut replaced = false;
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);
replaced = true;
}
}
if replaced {
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")

View file

@ -74,6 +74,51 @@ async fn classes_prepend_ignores_empty_input() {
assert_classes(&p, Some("a b"));
}
// **< PropsOp::replace_classes >********************************************************************
#[pagetop::test]
async fn classes_replace_removes_targets_and_inserts_new_at_min_position() {
let p = Props::classes("a b c d").with_prop(PropsOp::replace_classes("c a", "x y"));
assert_classes(&p, Some("x y b d"));
}
#[pagetop::test]
async fn classes_replace_when_none_found_does_nothing() {
let p = Props::classes("a b").with_prop(PropsOp::replace_classes("x y", "c d"));
assert_classes(&p, Some("a b"));
}
#[pagetop::test]
async fn classes_replace_is_case_insensitive_on_targets_and_new_values_are_normalized() {
let p = Props::classes("btn btn-primary active")
.with_prop(PropsOp::replace_classes("BTN-PRIMARY", "Btn-Secondary"));
assert_classes(&p, Some("btn btn-secondary active"));
}
#[pagetop::test]
async fn classes_replace_with_empty_new_removes_only() {
let p = Props::classes("a b c").with_prop(PropsOp::replace_classes("b", " "));
assert_classes(&p, Some("a c"));
}
#[pagetop::test]
async fn classes_replace_dedups_against_existing_items() {
let p = Props::classes("a b c").with_prop(PropsOp::replace_classes("b", "c d"));
assert_classes(&p, Some("a d c"));
}
#[pagetop::test]
async fn classes_replace_ignores_target_whitespace_and_repetition() {
let p = Props::classes("a b c").with_prop(PropsOp::replace_classes(" b b ", "x y"));
assert_classes(&p, Some("a x y c"));
}
#[pagetop::test]
async fn classes_replace_rejects_non_ascii_targets_is_noop() {
let p = Props::classes("a b c").with_prop(PropsOp::replace_classes("b ñ", "x"));
assert_classes(&p, Some("a b c"));
}
// **< PropsOp::set / remove ("class") >************************************************************
#[pagetop::test]