♻️ (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:
parent
0121fad94a
commit
f9e87058d8
8 changed files with 707 additions and 319 deletions
|
|
@ -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
313
src/html/props.rs
Normal 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('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue