♻️ (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
|
|
@ -80,10 +80,9 @@ impl Extension for SuperMenu {
|
|||
))
|
||||
.with_item(navbar::Item::nav(
|
||||
Nav::new()
|
||||
.with_classes(
|
||||
ClassesOp::Add,
|
||||
.with_prop(PropsOp::add_classes(
|
||||
classes::Margin::with(Side::Start, ScaleSize::Auto).to_class(),
|
||||
)
|
||||
))
|
||||
.with_item(nav::Item::link(L10n::t("menus_item_sign_up", &LOC), |cx| {
|
||||
cx.route("/auth/sign-up")
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
fn assert_classes(c: &Classes, expected: Option<&str>) {
|
||||
let got = c.get();
|
||||
fn assert_classes(p: &Props, expected: Option<&str>) {
|
||||
let got = p.get_classes();
|
||||
assert_eq!(
|
||||
got.as_deref(),
|
||||
expected,
|
||||
|
|
@ -15,176 +15,154 @@ fn assert_classes(c: &Classes, expected: Option<&str>) {
|
|||
|
||||
#[pagetop::test]
|
||||
async fn classes_new_empty_and_whitespace_is_empty() {
|
||||
assert_classes(&Classes::new(""), None);
|
||||
assert_classes(&Classes::new(" "), None);
|
||||
assert_classes(&Classes::new("\t\n\r "), None);
|
||||
assert_classes(&Props::classes(""), None);
|
||||
assert_classes(&Props::classes(" "), None);
|
||||
assert_classes(&Props::classes("\t\n\r "), None);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_new_normalizes_and_dedups_and_preserves_first_occurrence_order() {
|
||||
let c = Classes::new("Btn btn BTN btn-primary BTN-PRIMARY");
|
||||
assert_classes(&c, Some("btn btn-primary"));
|
||||
assert!(c.contains("BTN"));
|
||||
assert!(c.contains("btn-primary"));
|
||||
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"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_get_returns_none_when_empty_some_when_not() {
|
||||
assert_classes(&Classes::new(" "), None);
|
||||
assert_classes(&Classes::new("a"), Some("a"));
|
||||
assert_classes(&Props::classes(" "), None);
|
||||
assert_classes(&Props::classes("a"), Some("a"));
|
||||
}
|
||||
|
||||
// **< Basic operations (add/prepend/set) >*********************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_add_appends_unique_and_normalizes() {
|
||||
let c = Classes::new("a b").with_classes(ClassesOp::Add, "C b D");
|
||||
assert_classes(&c, Some("a b c d"));
|
||||
let p = Props::classes("a b").with_prop(PropsOp::add_classes("C b D"));
|
||||
assert_classes(&p, Some("a b c d"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_add_ignores_empty_input() {
|
||||
let c = Classes::new("a b").with_classes(ClassesOp::Add, " \t");
|
||||
assert_classes(&c, Some("a b"));
|
||||
let p = Props::classes("a b").with_prop(PropsOp::add_classes(" \t"));
|
||||
assert_classes(&p, Some("a b"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_add_same_tokens() {
|
||||
let c = Classes::new("a b").with_classes(ClassesOp::Add, "A B a b");
|
||||
assert_classes(&c, Some("a b"));
|
||||
let p = Props::classes("a b").with_prop(PropsOp::add_classes("A B a b"));
|
||||
assert_classes(&p, Some("a b"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_add_rejects_non_ascii_is_noop() {
|
||||
let c = Classes::new("a b").with_classes(ClassesOp::Add, "c ñ d");
|
||||
assert_classes(&c, Some("a b"));
|
||||
let p = Props::classes("a b").with_prop(PropsOp::add_classes("c ñ d"));
|
||||
assert_classes(&p, Some("a b"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_prepend_inserts_at_front_preserving_new_order() {
|
||||
let c = Classes::new("c d").with_classes(ClassesOp::Prepend, "A b");
|
||||
assert_classes(&c, Some("a b c d"));
|
||||
let p = Props::classes("c d").with_prop(PropsOp::prepend_classes("A b"));
|
||||
assert_classes(&p, Some("a b c d"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_prepend_inserts_new_tokens_skipping_duplicates() {
|
||||
let c = Classes::new("b c").with_classes(ClassesOp::Prepend, "a b d");
|
||||
assert_classes(&c, Some("a d b c"));
|
||||
let p = Props::classes("b c").with_prop(PropsOp::prepend_classes("a b d"));
|
||||
assert_classes(&p, Some("a d b c"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_prepend_ignores_empty_input() {
|
||||
let c = Classes::new("a b").with_classes(ClassesOp::Prepend, "");
|
||||
assert_classes(&c, Some("a b"));
|
||||
let p = Props::classes("a b").with_prop(PropsOp::prepend_classes(""));
|
||||
assert_classes(&p, Some("a b"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_reset_replaces_entire_list_and_dedups() {
|
||||
let c = Classes::new("a b c").with_classes(ClassesOp::Reset, "X y y Z");
|
||||
assert_classes(&c, Some("x y z"));
|
||||
let p = Props::classes("a b c").with_prop(PropsOp::set("class", "X y y Z"));
|
||||
assert_classes(&p, Some("x y z"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_reset_with_empty_input_clears() {
|
||||
let base = Classes::new("a b");
|
||||
let c = base.with_classes(ClassesOp::Reset, " \n ");
|
||||
assert_classes(&c, None);
|
||||
let p = Props::classes("a b").with_prop(PropsOp::set("class", " \n "));
|
||||
assert_classes(&p, None);
|
||||
}
|
||||
|
||||
// **< Mutation operations (remove/toggle) >********************************************************
|
||||
#[pagetop::test]
|
||||
async fn classes_reset_with_non_ascii_is_noop() {
|
||||
let p = Props::classes("a b").with_prop(PropsOp::set("class", "ñ"));
|
||||
assert_classes(&p, Some("a b"));
|
||||
}
|
||||
|
||||
// **< Mutation operations (remove) >***************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_remove_is_case_insensitive() {
|
||||
let c = Classes::new("a b c d").with_classes(ClassesOp::Remove, "B D");
|
||||
assert_classes(&c, Some("a c"));
|
||||
let p = Props::classes("a b c d").with_prop(PropsOp::remove_classes("B D"));
|
||||
assert_classes(&p, Some("a c"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_remove_non_existing_is_noop() {
|
||||
let c = Classes::new("a b c").with_classes(ClassesOp::Remove, "x y z");
|
||||
assert_classes(&c, Some("a b c"));
|
||||
let p = Props::classes("a b c").with_prop(PropsOp::remove_classes("x y z"));
|
||||
assert_classes(&p, Some("a b c"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_remove_with_extra_whitespace() {
|
||||
let c = Classes::new("a b c d").with_classes(ClassesOp::Remove, " b\t\t \n d ");
|
||||
assert_classes(&c, Some("a c"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_toggle_removes_if_present_case_insensitive() {
|
||||
let c = Classes::new("a b c").with_classes(ClassesOp::Toggle, "B");
|
||||
assert_classes(&c, Some("a c"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_toggle_adds_if_missing_and_normalizes() {
|
||||
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "C");
|
||||
assert_classes(&c, Some("a b c"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_toggle_multiple_tokens_is_sequential_and_order_dependent() {
|
||||
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "C B A");
|
||||
assert_classes(&c, Some("c"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_toggle_duplicate_tokens_are_applied_sequentially() {
|
||||
let c = Classes::new("b").with_classes(ClassesOp::Toggle, "a a");
|
||||
assert_classes(&c, Some("b"));
|
||||
|
||||
let c = Classes::new("a b").with_classes(ClassesOp::Toggle, "a a");
|
||||
assert_classes(&c, Some("b a"));
|
||||
let p = Props::classes("a b c d").with_prop(PropsOp::remove_classes(" b\t\t \n d "));
|
||||
assert_classes(&p, Some("a c"));
|
||||
}
|
||||
|
||||
// **< Queries (contains) >*************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_contains_single() {
|
||||
let c = Classes::new("btn btn-primary");
|
||||
assert!(c.contains("btn"));
|
||||
assert!(c.contains("BTN"));
|
||||
assert!(!c.contains("missing"));
|
||||
let p = Props::classes("btn btn-primary");
|
||||
assert!(p.has_class("btn"));
|
||||
assert!(p.has_class("BTN"));
|
||||
assert!(!p.has_class("missing"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_contains_all_and_any() {
|
||||
let c = Classes::new("btn btn-primary active");
|
||||
let p = Props::classes("btn btn-primary active");
|
||||
|
||||
assert!(c.contains("btn active"));
|
||||
assert!(c.contains("BTN BTN-PRIMARY"));
|
||||
assert!(!c.contains("btn missing"));
|
||||
assert!(p.has_class("btn active"));
|
||||
assert!(p.has_class("BTN BTN-PRIMARY"));
|
||||
assert!(!p.has_class("btn missing"));
|
||||
|
||||
assert!(c.contains_any("missing active"));
|
||||
assert!(c.contains_any("BTN-PRIMARY missing"));
|
||||
assert!(!c.contains_any("missing other"));
|
||||
assert!(p.has_any_class("missing active"));
|
||||
assert!(p.has_any_class("BTN-PRIMARY missing"));
|
||||
assert!(!p.has_any_class("missing other"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_contains_empty_and_whitespace_is_false() {
|
||||
let c = Classes::new("a b");
|
||||
assert!(!c.contains(""));
|
||||
assert!(!c.contains(" \t"));
|
||||
assert!(!c.contains_any(""));
|
||||
assert!(!c.contains_any(" \n "));
|
||||
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 "));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_contains_non_ascii_is_false() {
|
||||
let c = Classes::new("a b");
|
||||
assert!(!c.contains("ñ"));
|
||||
assert!(!c.contains_any("a ñ"));
|
||||
let p = Props::classes("a b");
|
||||
assert!(!p.has_class("ñ"));
|
||||
assert!(!p.has_any_class("a ñ"));
|
||||
}
|
||||
|
||||
// **< Properties / regression (combined sequences, ordering) >*************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn classes_order_is_stable_for_existing_items() {
|
||||
let c = Classes::new("a b c")
|
||||
.with_classes(ClassesOp::Add, "d") // a b c d
|
||||
.with_classes(ClassesOp::Prepend, "x") // x a b c d
|
||||
.with_classes(ClassesOp::Remove, "b") // x a c d
|
||||
.with_classes(ClassesOp::Add, "b"); // x a c d b
|
||||
assert_classes(&c, Some("x a c d b"));
|
||||
let p = Props::classes("a b c")
|
||||
.with_prop(PropsOp::add_classes("d")) // a b c d
|
||||
.with_prop(PropsOp::prepend_classes("x")) // x a b c d
|
||||
.with_prop(PropsOp::remove_classes("b")) // x a c d
|
||||
.with_prop(PropsOp::add_classes("b")); // x a c d b
|
||||
assert_classes(&p, Some("x a c d b"));
|
||||
}
|
||||
|
|
|
|||
304
tests/html_props.rs
Normal file
304
tests/html_props.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
// **< Construction & invariants >******************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_default_renders_nothing() {
|
||||
assert_eq!(
|
||||
html! { span (Props::default()) {} }.into_string(),
|
||||
"<span></span>"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_new_creates_first_attr() {
|
||||
let p = Props::new("hx-get", "/api");
|
||||
assert_eq!(p.get_prop("hx-get"), Some("/api"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_get_missing_key_returns_none() {
|
||||
let p = Props::new("hx-get", "/api");
|
||||
assert_eq!(p.get_prop("hx-post"), None);
|
||||
assert_eq!(p.get_prop(""), None);
|
||||
}
|
||||
|
||||
// **< Props::classes >*****************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_classes_renders_class_attribute() {
|
||||
let p = Props::classes("btn btn-primary");
|
||||
assert_eq!(
|
||||
html! { button (p) { "OK" } }.into_string(),
|
||||
r#"<button class="btn btn-primary">OK</button>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_classes_empty_input_renders_no_class_attribute() {
|
||||
let p = Props::classes(" ");
|
||||
assert_eq!(
|
||||
html! { button (p) { "OK" } }.into_string(),
|
||||
"<button>OK</button>"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_classes_can_be_extended_with_with_prop() {
|
||||
let p = Props::classes("btn").with_prop(PropsOp::add_classes("active"));
|
||||
assert_eq!(
|
||||
html! { button (p) { "OK" } }.into_string(),
|
||||
r#"<button class="btn active">OK</button>"#
|
||||
);
|
||||
}
|
||||
|
||||
// **< PropsOp::set >*******************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_set_adds_new_attrs() {
|
||||
let p = Props::default()
|
||||
.with_prop(PropsOp::set("hx-get", "/api"))
|
||||
.with_prop(PropsOp::set("hx-swap", "outerHTML"));
|
||||
assert_eq!(p.get_prop("hx-get"), Some("/api"));
|
||||
assert_eq!(p.get_prop("hx-swap"), Some("outerHTML"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_set_replaces_existing_value() {
|
||||
let p = Props::new("hx-get", "/old").with_prop(PropsOp::set("hx-get", "/new"));
|
||||
assert_eq!(p.get_prop("hx-get"), Some("/new"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_set_does_not_create_duplicate_key() {
|
||||
// Reasignar la misma clave debe reemplazar el valor, no añadir una entrada duplicada.
|
||||
let p = Props::new("key", "v1").with_prop(PropsOp::set("key", "v2"));
|
||||
assert_eq!(
|
||||
html! { span (p) {} }.into_string(),
|
||||
r#"<span key="v2"></span>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_set_preserves_insertion_order() {
|
||||
let p = Props::new("a", "1")
|
||||
.with_prop(PropsOp::set("b", "2"))
|
||||
.with_prop(PropsOp::set("c", "3"));
|
||||
assert_eq!(
|
||||
html! { span (p) {} }.into_string(),
|
||||
r#"<span a="1" b="2" c="3"></span>"#
|
||||
);
|
||||
}
|
||||
|
||||
// **< PropsOp::remove >****************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_remove_existing_attr() {
|
||||
let p = Props::new("a", "1")
|
||||
.with_prop(PropsOp::set("b", "2"))
|
||||
.with_prop(PropsOp::remove("a"));
|
||||
assert_eq!(p.get_prop("a"), None);
|
||||
assert_eq!(p.get_prop("b"), Some("2"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_remove_nonexistent_key_is_noop() {
|
||||
let p = Props::new("a", "1").with_prop(PropsOp::remove("missing"));
|
||||
assert_eq!(p.get_prop("a"), Some("1"));
|
||||
assert_eq!(p.get_prop("missing"), None);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_renders_nothing_after_removing_last_attr() {
|
||||
let p = Props::new("only", "one").with_prop(PropsOp::remove("only"));
|
||||
assert_eq!(html! { span (p) {} }.into_string(), "<span></span>");
|
||||
}
|
||||
|
||||
// **< HTML Escaped >*******************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_escapes_ampersand_and_angle_brackets_in_value() {
|
||||
let p = Props::new("data-info", "a&b<c>d");
|
||||
assert_eq!(
|
||||
html! { span (p) {} }.into_string(),
|
||||
r#"<span data-info="a&b<c>d"></span>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_escapes_double_quotes_in_value() {
|
||||
let p = Props::new("data-label", r#"say "hello""#);
|
||||
assert_eq!(
|
||||
html! { span (p) {} }.into_string(),
|
||||
r#"<span data-label="say "hello""></span>"#
|
||||
);
|
||||
}
|
||||
|
||||
// **< Integration with html! >*********************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_empty_in_html_macro_produces_no_attributes() {
|
||||
// Una Props vacía no debe emitir ni siquiera un espacio en blanco extra.
|
||||
let p = Props::default();
|
||||
assert_eq!(
|
||||
html! { button (p) { "x" } }.into_string(),
|
||||
"<button>x</button>"
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_single_attr_in_html_macro() {
|
||||
let p = Props::new("hx-get", "/api");
|
||||
assert_eq!(
|
||||
html! { button (p) { "Load" } }.into_string(),
|
||||
r#"<button hx-get="/api">Load</button>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_multiple_attrs_preserve_order_in_html_macro() {
|
||||
let p = Props::new("hx-get", "/api")
|
||||
.with_prop(PropsOp::set("hx-target", "#result"))
|
||||
.with_prop(PropsOp::set("hx-swap", "outerHTML"));
|
||||
assert_eq!(
|
||||
html! { button (p) {} }.into_string(),
|
||||
r##"<button hx-get="/api" hx-target="#result" hx-swap="outerHTML"></button>"##
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_alongside_class_and_id_in_html_macro() {
|
||||
// El splice siempre se emite después de class e id, independientemente del orden escrito.
|
||||
let p = Props::new("hx-get", "/api");
|
||||
assert_eq!(
|
||||
html! { button #mybtn .btn (p) { "Go" } }.into_string(),
|
||||
r#"<button class="btn" id="mybtn" hx-get="/api">Go</button>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_alongside_named_attr_renders_after_it() {
|
||||
let p = Props::new("hx-get", "/api");
|
||||
assert_eq!(
|
||||
html! { button type="button" (p) {} }.into_string(),
|
||||
r#"<button type="button" hx-get="/api"></button>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_multiple_splices_in_same_element() {
|
||||
let p1 = Props::new("hx-get", "/api");
|
||||
let p2 = Props::new("hx-swap", "outerHTML");
|
||||
assert_eq!(
|
||||
html! { button (p1) (p2) {} }.into_string(),
|
||||
r#"<button hx-get="/api" hx-swap="outerHTML"></button>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_inline_construction_in_html_macro() {
|
||||
assert_eq!(
|
||||
html! { button (Props::new("hx-get", "/api")) { "Go" } }.into_string(),
|
||||
r#"<button hx-get="/api">Go</button>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_conditional_expression_in_html_macro() {
|
||||
for (active, expected) in [
|
||||
(true, r#"<button hx-get="/api">x</button>"#),
|
||||
(false, "<button>x</button>"),
|
||||
] {
|
||||
let markup = html! {
|
||||
button (if active { Props::new("hx-get", "/api") } else { Props::default() }) { "x" }
|
||||
};
|
||||
assert_eq!(markup.into_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_splice_empty_string_emits_nothing() {
|
||||
// Un splice vacío no emite ningún atributo ni espacio extra.
|
||||
assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>");
|
||||
}
|
||||
|
||||
// **< is_props_empty / is_classes_empty >**********************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_is_props_empty_on_default() {
|
||||
assert!(Props::default().is_props_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_is_props_empty_false_after_set() {
|
||||
assert!(!Props::new("hx-get", "/api").is_props_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_is_props_empty_true_after_removing_last_attr() {
|
||||
let p = Props::new("only", "one").with_prop(PropsOp::remove("only"));
|
||||
assert!(p.is_props_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_is_classes_empty_on_default() {
|
||||
assert!(Props::default().is_classes_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_is_classes_empty_false_after_add_classes() {
|
||||
assert!(!Props::classes("btn").is_classes_empty());
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_is_classes_empty_true_after_remove_class() {
|
||||
let p = Props::classes("btn").with_prop(PropsOp::remove("class"));
|
||||
assert!(p.is_classes_empty());
|
||||
}
|
||||
|
||||
// **< Regression & edge cases >********************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_hx_target_value_with_hash_renders_correctly() {
|
||||
// Regresión: r#"..."# se cerraba prematuramente al encontrar `"#lista"`.
|
||||
let p = Props::new("hx-target", "#list");
|
||||
assert_eq!(
|
||||
html! { button (p) {} }.into_string(),
|
||||
r##"<button hx-target="#list"></button>"##
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_with_empty_value_renders_attr_with_empty_value() {
|
||||
let p = Props::new("data-expanded", "");
|
||||
assert_eq!(
|
||||
html! { span (p) {} }.into_string(),
|
||||
r#"<span data-expanded=""></span>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_chained_set_and_remove_yields_expected_state() {
|
||||
let p = Props::new("a", "1")
|
||||
.with_prop(PropsOp::set("b", "2"))
|
||||
.with_prop(PropsOp::set("c", "3"))
|
||||
.with_prop(PropsOp::remove("b"))
|
||||
.with_prop(PropsOp::set("a", "updated"));
|
||||
assert_eq!(p.get_prop("a"), Some("updated"));
|
||||
assert_eq!(p.get_prop("b"), None);
|
||||
assert_eq!(p.get_prop("c"), Some("3"));
|
||||
assert_eq!(
|
||||
html! { span (p) {} }.into_string(),
|
||||
r#"<span a="updated" c="3"></span>"#
|
||||
);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_with_empty_attr_name_renders_without_validation() {
|
||||
// Comportamiento documentado: los nombres no se validan; el HTML resultante no es estándar.
|
||||
let p = Props::new("", "val");
|
||||
assert_eq!(
|
||||
html! { span (p) {} }.into_string(),
|
||||
r#"<span ="val"></span>"#
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue