♻️ (html): Migra Classes/ClassesOp a Props/PropsOp

Introduce `Props`/`PropsOp` para gestionar pares `atributo="valor"` y
clases CSS para aplicar en componentes.

- Constructores `Props::new()`, `Props::classes()` y `Props::default()`.
- `Page.body_classes` reemplazado por `body_props` (permite atributos
  arbitrarios en `<body>`, no sólo clases).
- Tests nuevos para atributos y reescritos para clases.
This commit is contained in:
Manuel Cillero 2026-06-12 01:55:07 +02:00
parent 0121fad94a
commit f9e87058d8
8 changed files with 707 additions and 319 deletions

View file

@ -8,8 +8,8 @@ use crate::prelude::*;
pub struct Block {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al bloque.
classes: Classes,
/// Devuelve los atributos HTML y clases CSS del bloque.
props: Props,
/// Devuelve el título del bloque.
title: L10n,
/// Devuelve la lista de componentes hijo del bloque.
@ -26,7 +26,7 @@ impl Component for Block {
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, "block");
self.props.alter_prop(PropsOp::prepend_classes("block"));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
@ -39,7 +39,7 @@ impl Component for Block {
let id = cx.required_id::<Self>(self.id(), 1);
Ok(html! {
div id=(&id) class=[self.classes().get()] {
div id=(&id) (self.props()) {
@if let Some(title) = self.title().lookup(cx) {
h2 class="block__title" { span { (title) } }
}
@ -59,10 +59,10 @@ impl Block {
self
}
/// Modifica la lista de clases CSS aplicadas al bloque.
/// Modifica los atributos HTML o las clases CSS del bloque.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}

View file

@ -1,7 +1,7 @@
//! HTML en código.
mod maud;
pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, display, html, html_private};
pub(crate) mod maud;
pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html_private};
mod route;
pub use route::RoutePath;
@ -22,8 +22,8 @@ pub use logo::PageTopSvg;
mod attr;
pub use attr::{Attr, AttrId, AttrName, AttrValue};
mod classes;
pub use classes::{Classes, ClassesOp};
mod props;
pub use props::{Props, PropsOp};
mod unit;
pub use unit::UnitValue;

View file

@ -1,205 +0,0 @@
use crate::{AutoDefault, 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()`].
///
/// # Orden de las clases y CSS
///
/// El navegador aplica los estilos según la especificidad de los selectores y el orden en que las
/// reglas aparecen en la **hoja de estilos**, no por el orden de las clases en el atributo `class`.
/// Por tanto, `"btn active"` y `"active btn"` producen exactamente el mismo resultado visual.
///
/// Las operaciones [`Add`](Self::Add) y [`Prepend`](Self::Prepend) permiten controlar ese orden
/// únicamente por legibilidad o por convención de proyecto, no porque afecte al comportamiento
/// del navegador.
///
/// # Reemplazar una clase
///
/// Para sustituir una clase por otra encadena [`Remove`](Self::Remove) y [`Add`](Self::Add):
/// ```rust
/// # use pagetop::prelude::*;
/// let c = Classes::new("btn btn-primary active")
/// .with_classes(ClassesOp::Remove, "btn-primary")
/// .with_classes(ClassesOp::Add, "btn-secondary");
/// assert_eq!(c.get(), Some("btn active btn-secondary".to_string()));
/// ```
#[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,
/// 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.
Reset,
}
/// 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
///
/// - El orden de las clases no afecta al resultado en CSS; las operaciones de ordenación
/// ([`Add`](ClassesOp::Add), [`Prepend`](ClassesOp::Prepend)) son puramente estéticas.
/// - 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::Remove, "active")
/// .with_classes(ClassesOp::Add, "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<String>);
impl Classes {
/// Crea una nueva lista de clases a partir de la clase o clases proporcionadas en `classes`.
pub fn new(classes: impl AsRef<str>) -> 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<str>) -> 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::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::Reset => {
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<Item = &'a str>,
{
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 `true` si no hay clases.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Devuelve la cadena de clases, si existe.
pub fn get(&self) -> Option<String> {
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<str>) -> 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<str>) -> 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))
}
}

313
src/html/props.rs Normal file
View file

@ -0,0 +1,313 @@
use crate::html::maud::{Escaper, Render};
use crate::{AutoDefault, CowStr, builder_fn, util};
use std::fmt::Write;
// **< PropsOp >************************************************************************************
/// Operaciones disponibles sobre atributos HTML y clases CSS en [`Props`].
///
/// Cada variante es autocontenida, lleva todos los datos que necesita para ejecutarse. El método
/// recomendado para construirlas es usar los constructores asociados ([`set()`](Self::set),
/// [`remove()`](Self::remove), [`add_classes()`](Self::add_classes), etc.).
///
/// Las variantes `*Classes` operan siempre sobre la lista de clases CSS para el componente.
///
/// Cuando se usa `"class"` como nombre de atributo en `Set` o `Remove` la operación se aplica a la
/// lista de clases completa. Así, `Set("class", ...)` reemplaza la lista de clases completa por las
/// nuevas clases indicadas, y `Remove("class")` vacía la lista de clases.
#[derive(Clone, Debug, PartialEq)]
pub enum PropsOp {
/// Añade el atributo o sustituye su valor si ya existe. Usar `"class"` como nombre reemplaza la
/// lista completa de clases por las nuevas indicadas; la operación se ignora si el valor
/// contiene caracteres no ASCII.
Set(CowStr, CowStr),
/// Elimina el atributo indicado. Usar `"class"` como nombre vacía la lista de clases.
Remove(CowStr),
/// Añade las clases que no existan al final de la lista.
AddClasses(CowStr),
/// Añade las clases que no existan al principio de la lista.
PrependClasses(CowStr),
/// Elimina las clases indicadas de la lista.
RemoveClasses(CowStr),
}
impl PropsOp {
/// Crea la variante [`Set`](Self::Set) con nombre y valor del atributo.
pub fn set(name: impl Into<CowStr>, value: impl Into<CowStr>) -> Self {
Self::Set(name.into(), value.into())
}
/// Crea la variante [`Remove`](Self::Remove) para el atributo indicado.
pub fn remove(name: impl Into<CowStr>) -> Self {
Self::Remove(name.into())
}
/// Crea la variante [`AddClasses`](Self::AddClasses) con las clases indicadas.
pub fn add_classes(classes: impl Into<CowStr>) -> Self {
Self::AddClasses(classes.into())
}
/// Crea la variante [`PrependClasses`](Self::PrependClasses) con las clases indicadas.
pub fn prepend_classes(classes: impl Into<CowStr>) -> Self {
Self::PrependClasses(classes.into())
}
/// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con las clases indicadas.
pub fn remove_classes(classes: impl Into<CowStr>) -> Self {
Self::RemoveClasses(classes.into())
}
}
// **< Props >**************************************************************************************
/// Colección de pares `atributo="valor"` y clases CSS para aplicar en componentes.
///
/// Permite añadir dinámicamente pares `atributo="valor"` y clases CSS al elemento raíz de un
/// componente. Al renderizar los atributos en `html!` primero emite el atributo `class` (si hay
/// clases) y luego el resto de atributos.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let props = Props::new("hx-get", "/api/items")
/// .with_prop(PropsOp::set("hx-target", "#lista"))
/// .with_prop(PropsOp::set("hx-swap", "outerHTML"));
///
/// let markup = html! {
/// button (props) { "Cargar" }
/// };
///
/// assert_eq!(
/// markup.into_string(),
/// r##"<button hx-get="/api/items" hx-target="#lista" hx-swap="outerHTML">Cargar</button>"##
/// );
/// ```
///
/// # Clases CSS
///
/// ```rust
/// # use pagetop::prelude::*;
/// let props = Props::default()
/// .with_prop(PropsOp::add_classes("btn btn-primary"))
/// .with_prop(PropsOp::add_classes("active"));
///
/// let markup = html! { button (props) { "OK" } };
/// assert_eq!(markup.into_string(), r#"<button class="btn btn-primary active">OK</button>"#);
/// ```
///
/// # Integración en componentes
///
/// El patrón recomendado es añadir un campo `props: Props` con su método *builder* delegado:
///
/// ```rust
/// # use pagetop::prelude::*;
/// #[derive(AutoDefault, Clone, Getters)]
/// pub struct MyButton {
/// label: L10n,
/// props: Props,
/// }
///
/// impl Component for MyButton {
/// fn new() -> Self { Self::default() }
///
/// fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
/// Ok(html! {
/// button (self.props()) {
/// (self.label().using(cx))
/// }
/// })
/// }
/// }
///
/// impl MyButton {
/// /// Modifica los atributos HTML o las clases CSS del elemento raíz.
/// #[builder_fn]
/// pub fn with_prop(mut self, op: PropsOp) -> Self {
/// self.props.alter_prop(op);
/// self
/// }
/// }
/// ```
#[derive(AutoDefault, Clone, Debug)]
pub struct Props {
attrs: Vec<(CowStr, CowStr)>,
classes: Vec<String>,
}
impl Props {
/// Crea una colección con un primer atributo ya establecido.
pub fn new(name: impl Into<CowStr>, value: impl Into<CowStr>) -> Self {
Self::default().with_prop(PropsOp::set(name, value))
}
/// Crea una colección con las clases CSS iniciales indicadas.
pub fn classes(classes: impl Into<CowStr>) -> Self {
Self::default().with_prop(PropsOp::add_classes(classes))
}
// **< Props BUILDER >**************************************************************************
/// Modifica los atributos o clases según la operación indicada.
///
/// - [`Set(name, value)`](PropsOp::Set) añade el atributo o reemplaza su valor.
/// `Set("class", ...)` reemplaza la lista de clases completa.
/// - [`Remove(name)`](PropsOp::Remove) elimina el atributo. `Remove("class")` vacía la lista de
/// clases.
/// - [`AddClasses(clases)`](PropsOp::AddClasses) añade clases al final (sin duplicados).
/// - [`PrependClasses(clases)`](PropsOp::PrependClasses) añade clases al principio (sin
/// duplicados).
/// - [`RemoveClasses(clases)`](PropsOp::RemoveClasses) elimina las clases indicadas.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
match op {
PropsOp::Set(name, value) => {
if name.as_ref() == "class" {
if let Some(normalized) =
util::normalize_ascii_or_empty(value.as_ref(), "Props::with_prop")
{
self.classes.clear();
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0);
}
} else if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &name) {
self.attrs[pos].1 = value;
} else {
self.attrs.push((name, value));
}
}
PropsOp::Remove(name) => {
if name.as_ref() == "class" {
self.classes.clear();
} else {
self.attrs.retain(|(k, _)| k != &name);
}
}
PropsOp::AddClasses(classes) => {
let Some(normalized) =
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
else {
return self;
};
let pos = self.classes.len();
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), pos);
}
PropsOp::PrependClasses(classes) => {
let Some(normalized) =
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
else {
return self;
};
self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0);
}
PropsOp::RemoveClasses(classes) => {
let Some(normalized) =
util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop")
else {
return self;
};
self.classes.retain(|c| {
!normalized
.as_ref()
.split_ascii_whitespace()
.any(|r| r == c.as_str())
});
}
}
self
}
// **< Props GETTERS >**************************************************************************
/// Devuelve el valor del atributo indicado, si existe.
pub fn get_prop(&self, name: impl AsRef<str>) -> Option<&str> {
let name = name.as_ref();
self.attrs
.iter()
.find(|(k, _)| k.as_ref() == name)
.map(|(_, v)| v.as_ref())
}
/// Devuelve `true` si no hay ningún atributo definido.
pub fn is_props_empty(&self) -> bool {
self.attrs.is_empty()
}
/// Devuelve la lista de clases como cadena de texto, si hay clases definidas.
pub fn get_classes(&self) -> Option<String> {
if self.classes.is_empty() {
None
} else {
Some(self.classes.join(" "))
}
}
/// Devuelve `true` si no hay ninguna clase definida.
pub fn is_classes_empty(&self) -> bool {
self.classes.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 {
let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else {
return false;
};
normalized
.as_ref()
.split_ascii_whitespace()
.any(|class| self.classes.iter().any(|c| c == class))
}
// **< Props PRIVADO >**************************************************************************
#[inline]
fn insert_classes<'a, I>(&mut self, classes: I, mut pos: usize)
where
I: IntoIterator<Item = &'a str>,
{
for class in classes {
if !self.classes.iter().any(|c| c == class) {
let class = class.to_string();
if pos >= self.classes.len() {
self.classes.push(class);
} else {
self.classes.insert(pos, class);
}
pos += 1;
}
}
}
}
#[doc(hidden)]
impl Render for Props {
fn render_to(&self, w: &mut String) {
if let Some((first, rest)) = self.classes.split_first() {
w.push_str(" class=\"");
let _ = write!(Escaper::new(w), "{}", first);
for class in rest {
w.push(' ');
let _ = write!(Escaper::new(w), "{}", class);
}
w.push('"');
}
for (name, value) in &self.attrs {
w.push(' ');
let _ = write!(Escaper::new(w), "{}", name);
w.push_str("=\"");
let _ = write!(Escaper::new(w), "{}", value);
w.push('"');
}
}
}

View file

@ -20,8 +20,7 @@ use crate::base::action;
use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual};
use crate::core::theme::{DefaultRegion, Region, RegionRef, TemplateRef, ThemeRef};
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
use crate::html::{Attr, AttrId};
use crate::html::{Classes, ClassesOp};
use crate::html::{Attr, AttrId, Props, PropsOp};
use crate::html::{DOCTYPE, Markup, html};
use crate::locale::{CharacterDirection, L10n, LangId, LanguageIdentifier};
use crate::web::HttpRequest;
@ -91,7 +90,7 @@ pub struct Page {
metadata : Vec<(&'static str, &'static str)>,
properties : Vec<(&'static str, &'static str)>,
body_id : AttrId,
body_classes: Classes,
body_props : Props,
context : Context,
}
@ -108,7 +107,7 @@ impl Page {
metadata : Vec::default(),
properties : Vec::default(),
body_id : AttrId::default(),
body_classes: Classes::default(),
body_props : Props::default(),
context : Context::new(Some(request)),
}
}
@ -150,10 +149,10 @@ impl Page {
self
}
/// Modifica las clases CSS del elemento `<body>` con una operación sobre [`Classes`].
/// Modifica los atributos HTML o las clases CSS del elemento `<body>`.
#[builder_fn]
pub fn with_body_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.body_classes.alter_classes(op, classes);
pub fn with_body_props(mut self, op: PropsOp) -> Self {
self.body_props.alter_prop(op);
self
}
@ -184,9 +183,9 @@ impl Page {
&self.body_id
}
/// Devuelve las clases CSS del elemento `<body>`.
pub fn body_classes(&self) -> &Classes {
&self.body_classes
/// Devuelve los atributos HTML y clases CSS del elemento `<body>`.
pub fn body_props(&self) -> &Props {
&self.body_props
}
/// Devuelve una referencia mutable al [`Context`] de la página.
@ -262,7 +261,7 @@ impl Page {
head {
(head)
}
body id=[self.body_id().get()] class=[self.body_classes().get()] {
body id=[self.body_id().get()] (self.body_props()) {
(body)
}
}