diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index 0d489427..eb32d1a7 100644 --- a/extensions/pagetop-htmx/src/hx.rs +++ b/extensions/pagetop-htmx/src/hx.rs @@ -25,13 +25,13 @@ //! ```rust,no_run //! # use pagetop::prelude::*; //! # use pagetop_htmx::prelude::*; -//! # let cx = Context::default(); +//! # let mut cx = Context::default(); //! let props = Props::new(hx::GET, "/api/items") //! .with_prop(PropsOp::set(hx::TARGET, "#list")) //! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML)); //! //! let markup = html! { -//! button (props.unpack(&cx)) { "Load" } +//! button (props.unpack(&mut cx)) { "Load" } //! }; //! ``` //! diff --git a/src/base/component/breadcrumb/crumb.rs b/src/base/component/breadcrumb/crumb.rs index ebf1f894..5a8a1080 100644 --- a/src/base/component/breadcrumb/crumb.rs +++ b/src/base/component/breadcrumb/crumb.rs @@ -88,7 +88,7 @@ impl Crumb { } // Renderiza con enlace si tiene ruta, o texto plano en otro caso. Sólo lo usa `Breadcrumb`. - pub(super) fn render_crumb(&self, cx: &Context) -> Markup { + pub(super) fn render_crumb(&self, cx: &mut Context) -> Markup { let label = self.label().using(cx); match self.route() { Some(route) => html! { diff --git a/src/base/component/table/column.rs b/src/base/component/table/column.rs index 9b9fbc5c..5a179187 100644 --- a/src/base/component/table/column.rs +++ b/src/base/component/table/column.rs @@ -85,7 +85,7 @@ impl Column { // Traduce la etiqueta y, si la columna es ordenable, la envuelve en su enlace con `aria-sort` // y las clases `table-sort*` ya resueltas por `table::SortLink`. Sólo lo usa `Table` al // renderizar. - pub(super) fn render_header(&self, cx: &Context) -> Markup { + pub(super) fn render_header(&self, cx: &mut Context) -> Markup { let label = self.label().using(cx); let Some(sort) = self.sort() else { diff --git a/src/html/logo.rs b/src/html/logo.rs index bdf49dd0..61196e5c 100644 --- a/src/html/logo.rs +++ b/src/html/logo.rs @@ -71,7 +71,7 @@ impl PageTopSvg { /// [`Lc::none()`]: crate::locale::Lc::none /// [`Image`]: crate::base::component::Image /// [`image::Source::Logo`]: crate::base::component::image::Source::Logo - pub fn markup_with(&self, cx: &Context, props: &Props, label: Lc) -> Markup { + pub fn markup_with(&self, cx: &mut Context, props: &Props, label: Lc) -> Markup { let label = label.lookup(cx); html! { svg diff --git a/src/html/props.rs b/src/html/props.rs index c001d953..4e9d1ce2 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -1,1086 +1,11 @@ -use crate::core::TypeInfo; -use crate::core::component::Context; -use crate::html::flex::FlexItem; -use crate::html::maud::{Escaper, RenderAttrs}; -use crate::{AutoDefault, CowStr, builder_impl, trace, util}; +mod op; +pub use op::PropsOp; -use thiserror::Error; +mod extra; +pub use extra::PropsExtra; -use std::any::Any; -use std::collections::HashMap; -use std::fmt::{self, Write}; -use std::panic::Location; -use std::sync::Arc; +mod error; +pub use error::PropsError; -// **< PropsExtra >********************************************************************************* - -/// Encapsula un valor tipado extra para almacenar en [`Props`]. -/// -/// Internamente usa [`Arc`] para que [`Props`] pueda implementar [`Clone`] sin requerir que los -/// valores almacenados sean clonables. El nombre del tipo almacenado permite generar mensajes de -/// error precisos. -pub struct PropsExtra { - value: Arc, - type_name: &'static str, -} - -impl Clone for PropsExtra { - fn clone(&self) -> Self { - Self { - value: Arc::clone(&self.value), - type_name: self.type_name, - } - } -} - -impl fmt::Debug for PropsExtra { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "<{}>", self.type_name) - } -} - -// **< PropsError >********************************************************************************* - -/// Errores de acceso a valores extra de [`Props`]. -#[derive(Debug, PartialEq, Eq, Error)] -pub enum PropsError { - /// La clave no existe. Incluye la clave (`key`). - #[error("extra \"{key}\" not found")] - ExtraNotFound { key: &'static str }, - /// La clave existe pero el tipo solicitado no coincide con el almacenado. Incluye la clave - /// (`key`), tipo esperado (`expected`) y tipo realmente encontrado (`found`) para facilitar el - /// diagnóstico. - #[error("type mismatch for extra \"{key}\": expected \"{expected}\", found \"{found}\"")] - ExtraTypeMismatch { - key: &'static str, - expected: &'static str, - found: &'static str, - }, -} - -// **< PropsOp >************************************************************************************ - -/// Operaciones sobre el identificador, clases CSS, atributos HTML y valores extra en [`Props`]. -/// -/// Cada variante lleva los datos necesarios para ejecutarse. El método recomendado para usarlas es -/// recurrir a los constructores asociados como [`set_id()`](Self::set_id), -/// [`add_classes()`](Self::add_classes), [`set()`](Self::set), etc. -/// -/// Las variantes `*Id` operan sobre el atributo `id` del componente. Cuando se usa `"id"` como -/// nombre de atributo en `Set`, el valor se normaliza igual que [`SetId`](Self::SetId). -/// -/// Las variantes `*Classes` gestionan la lista de clases CSS. Además, `Set("class", ...)` -/// reemplaza la lista completa de clases y `Remove("class")` la vacía. -/// -/// Las variantes `*Style` gestionan las declaraciones de estilos para el atributo `style`, con una -/// propiedad cada vez. Además, `Set("style", ...)` reemplaza la lista completa de estilos y -/// `Remove("style")` la vacía. -/// -/// Las variantes [`Set`](Self::Set) y [`Remove`](Self::Remove) son operaciones de propósito -/// general. `Set` añade o reemplaza cualquier atributo HTML por nombre y valor, y `Remove` lo -/// elimina. Los atributos `id`, `class` y `style` tienen semántica especial documentada en estas -/// variantes. -/// -/// [`Rename`](Self::Rename) cambia el nombre de un atributo genérico conservando su valor; no -/// reconoce `"id"`, `"class"` ni `"style"` como origen ni destino. Está pensada para que un tema -/// traduzca a su propio vocabulario los atributos que un componente ya expone, sin que éste tenga -/// que conocer ningún tema en concreto. -/// -/// Las variantes `*Extra` permiten añadir valores tipados usando una clave. Están pensadas para -/// ampliar el comportamiento de componentes ya existentes. Como no es posible añadir campos a la -/// estructura de un componente ya definido, temas y extensiones pueden definir un trait con nuevos -/// métodos que leen y escriben valores extra en [`Props`]. Esos valores se interpretan como si -/// fueran valores internos del componente para tomar decisiones durante el renderizado. -/// -/// Finalmente, [`FlexItem`](Self::FlexItem) aplica un posicionamiento Flexbox a nivel de ítem sobre -/// cualquier componente. -#[derive(Clone, Debug)] -pub enum PropsOp { - /// Establece el identificador del componente normalizando el valor: recorta espacios, convierte - /// a minúsculas y sustituye los espacios intermedios por `_`. Si el resultado es vacío, elimina - /// el identificador. - SetId(CowStr), - /// Establece el identificador del componente **sólo si aún no hay ninguno definido**. Aplica la - /// misma normalización que [`SetId`](Self::SetId); si el resultado es vacío, la operación no - /// tiene efecto. - EnsureId(CowStr), - /// Añade la clase o clases que no existan al final de la lista. La operación se ignora si el - /// valor contiene caracteres no ASCII. - AddClasses(CowStr), - /// 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 más** clases del primer valor por las clases indicadas en el segundo - /// valor, insertando las nuevas en la posición de la primera clase a sustituir encontrada, con - /// independencia del orden en que aparecen en el primer valor. Las que no existan se ignoran. - /// Si **ninguna** de las clases a sustituir existe, la operación no tiene efecto y no se - /// inserta nada. Se ignora si alguno de los dos valores contiene caracteres no ASCII. - ReplaceClasses(CowStr, CowStr), - /// A diferencia de [`ReplaceClasses`](Self::ReplaceClasses), exige que **todas** las clases del - /// primer valor estén presentes, independientemente de su orden; si falta una sola, la - /// operación no tiene efecto: ninguna clase se elimina ni se inserta. Si todas están presentes, - /// las sustituye por las clases indicadas en el segundo valor, insertando las nuevas en la - /// posición de la primera clase a sustituir encontrada. Se ignora si alguno de los dos valores - /// contiene caracteres no ASCII. - ReplaceAllClasses(CowStr, CowStr), - /// Elimina la clase o clases indicadas de la lista. La operación se ignora si el valor contiene - /// caracteres no ASCII. - RemoveClasses(CowStr), - /// Añade una declaración de estilo (propiedad, valor) o sustituye su valor si la propiedad ya - /// existe, conservando su posición; si no, se añade al final. A diferencia de las clases, el - /// valor admite caracteres no ASCII (p. ej. `content`, `font-family`) y distingue mayúsculas y - /// minúsculas. El nombre de la propiedad se normaliza a minúsculas. Si la propiedad o el valor - /// quedan vacíos tras recortar espacios, la operación se ignora. - AddStyle(CowStr, CowStr), - /// Elimina la propiedad de estilo indicada, si existe. - RemoveStyle(CowStr), - /// Añade un atributo o sustituye su valor si ya existe. - /// - /// Usar `"id"` como nombre de atributo aplica al valor la misma normalización que - /// [`SetId`](Self::SetId). - /// - /// Usar `"class"` como nombre de atributo reemplaza la lista completa de clases por las nuevas - /// indicadas; la operación se ignora si el valor contiene caracteres no ASCII. - /// - /// Usar `"style"` como nombre de atributo reemplaza la lista completa de estilos por los nuevos - /// indicados, interpretando el valor como declaraciones `"propiedad: valor"` separadas por `;` - /// (igual que el propio atributo `style` HTML). El separador `;` respeta paréntesis y comillas, - /// tal que valores como una *data URI* (`background: url(data:image/png;base64,...)`) o una - /// cadena con `;` (`content: "a;b"`) se interpretan correctamente. En cualquier caso, se - /// recomienda usar [`PropsOp::add_style()`](Self::add_style) para declarar estilos. - Set(CowStr, CowStr), - /// Si el primer atributo (origen) existe, lo renombra al segundo (destino), conservando su - /// valor; si el destino ya tiene su propio valor, se respeta sin sobrescribir y sólo se elimina - /// el origen. Si el origen no existe, la operación no tiene efecto. - /// - /// Sólo actúa sobre atributos genéricos, por lo que los nombres `"id"`, `"class"` y `"style"` - /// no se reconocen como origen ni destino. - Rename(CowStr, CowStr), - /// Elimina el atributo indicado. Usar `"id"` elimina el identificador; usar `"class"` vacía la - /// lista de clases; y usar `"style"` vacía la lista de estilos. - Remove(CowStr), - /// Almacena un valor extra tipado asociado a la clave indicada. Si ya existe uno con esa clave, - /// lo reemplaza. - SetExtra(&'static str, PropsExtra), - /// Elimina el valor extra asociado a la clave indicada, si existe. - RemoveExtra(&'static str), - /// Aplica un posicionamiento [`FlexItem`] a un componente particular en un contenedor [`Flex`]. - /// Añade directamente sus estilos Flexbox al propio componente, sin usar clases CSS. - /// - /// Existe como variante de `PropsOp`, y no como método builder de un componente, porque las - /// propiedades de un ítem Flexbox tienen sentido sobre **cualquier** componente que pueda - /// añadirse como hijo de un contenedor Flex (por ejemplo `Button`, `Nav`, un componente de - /// terceros, incluso otro componente que sea, a su vez, un contenedor Flex para sus propios - /// hijos). - /// - /// No existe una variante equivalente `PropsOp::Flex` para el componente contenedor. No hace - /// falta porque los componentes contenedores, como `Container` o `Navbar`, ofrecen su propio - /// `with_flex()` tipado y con introspección (p. ej. [`Container::flex()`]). - /// - /// [`Flex`]: crate::html::flex::Flex - /// [`Container::flex()`]: crate::base::component::Container::flex - FlexItem(FlexItem), -} - -impl PropsOp { - /// Crea la variante [`SetId`](Self::SetId) con el identificador indicado. - pub fn set_id(id: impl Into) -> Self { - Self::SetId(id.into()) - } - - /// Crea la variante [`EnsureId`](Self::EnsureId) con el identificador indicado. - pub fn ensure_id(id: impl Into) -> Self { - Self::EnsureId(id.into()) - } - - /// Crea la variante [`AddClasses`](Self::AddClasses) con la clase o clases indicadas. - pub fn add_classes(classes: impl Into) -> Self { - Self::AddClasses(classes.into()) - } - - /// Crea la variante [`PrependClasses`](Self::PrependClasses) con la clase o clases indicadas. - pub fn prepend_classes(classes: impl Into) -> Self { - 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())); - /// - /// // Basta con que exista alguna clase de `old` para aplicar el reemplazo. - /// let props = Props::classes("btn primary") - /// .with_prop(PropsOp::replace_classes("primary secondary", "danger")); - /// assert_eq!(props.get_classes(), Some("btn danger".to_string())); - /// ``` - pub fn replace_classes(old: impl Into, new: impl Into) -> Self { - Self::ReplaceClasses(old.into(), new.into()) - } - - /// Crea la variante [`ReplaceAllClasses`](Self::ReplaceAllClasses) con las clases a sustituir - /// (`old`) y las nuevas clases (`new`). - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let props = Props::classes("btn primary") - /// .with_prop(PropsOp::replace_all_classes("btn primary", "btn danger")); - /// assert_eq!(props.get_classes(), Some("btn danger".to_string())); - /// - /// // Si falta una sola clase de `old`, no hay reemplazo. - /// let props = Props::classes("btn primary") - /// .with_prop(PropsOp::replace_all_classes("primary secondary", "danger")); - /// assert_eq!(props.get_classes(), Some("btn primary".to_string())); - /// ``` - pub fn replace_all_classes(old: impl Into, new: impl Into) -> Self { - Self::ReplaceAllClasses(old.into(), new.into()) - } - - /// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con la clase o clases indicadas. - pub fn remove_classes(classes: impl Into) -> Self { - Self::RemoveClasses(classes.into()) - } - - /// Crea la variante [`AddStyle`](Self::AddStyle) con la propiedad y el valor de estilo - /// indicados. - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let props = Props::default() - /// .with_prop(PropsOp::add_style("color", "red")) - /// .with_prop(PropsOp::add_style("font-weight", "bold")) - /// .with_prop(PropsOp::add_style("color", "blue")); - /// assert_eq!(props.get_styles(), Some("color: blue; font-weight: bold".to_string())); - /// ``` - pub fn add_style(property: impl Into, value: impl Into) -> Self { - Self::AddStyle(property.into(), value.into()) - } - - /// Crea la variante [`RemoveStyle`](Self::RemoveStyle) para la propiedad de estilo indicada. - pub fn remove_style(property: impl Into) -> Self { - Self::RemoveStyle(property.into()) - } - - /// Crea la variante [`Set`](Self::Set) con nombre y valor del atributo. - pub fn set(name: impl Into, value: impl Into) -> Self { - Self::Set(name.into(), value.into()) - } - - /// Crea la variante [`Rename`](Self::Rename) para renombrar `from` a `to`. - /// - /// ```rust - /// # use pagetop::prelude::*; - /// let props = Props::new("data-dialog-toggle", "modal") - /// .with_prop(PropsOp::rename("data-dialog-toggle", "data-bs-toggle")); - /// assert_eq!(props.get_prop("data-bs-toggle"), Some("modal".to_string())); - /// assert_eq!(props.get_prop("data-dialog-toggle"), None); - /// - /// // Si el destino tiene su propio valor no se sobrescribe, sólo se elimina el origen. - /// let props = Props::new("data-bs-toggle", "collapse") - /// .with_prop(PropsOp::set("data-dialog-toggle", "modal")) - /// .with_prop(PropsOp::rename("data-dialog-toggle", "data-bs-toggle")); - /// assert_eq!(props.get_prop("data-bs-toggle"), Some("collapse".to_string())); - /// assert_eq!(props.get_prop("data-dialog-toggle"), None); - /// ``` - pub fn rename(from: impl Into, to: impl Into) -> Self { - Self::Rename(from.into(), to.into()) - } - - /// Crea la variante [`Remove`](Self::Remove) para el atributo indicado. - pub fn remove(name: impl Into) -> Self { - Self::Remove(name.into()) - } - - /// Crea la variante [`SetExtra`](Self::SetExtra) con la clave y el valor indicados. - /// - /// ```rust - /// # use pagetop::prelude::*; - /// const EXT_SIZE: &str = "myext.size"; - /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_SIZE, 42_u32)); - /// assert_eq!(props.extra_or(EXT_SIZE, 0_u32), 42); - /// ``` - pub fn set_extra(key: &'static str, value: T) -> Self { - Self::SetExtra( - key, - PropsExtra { - value: Arc::new(value), - type_name: TypeInfo::FullName.of::(), - }, - ) - } - - /// Crea la variante [`RemoveExtra`](Self::RemoveExtra) para la clave indicada. - pub fn remove_extra(key: &'static str) -> Self { - Self::RemoveExtra(key) - } - - /// Crea la variante [`FlexItem`](Self::FlexItem) con el posicionamiento indicado. - pub fn flex_item(placement: FlexItem) -> Self { - Self::FlexItem(placement) - } -} - -// **< Props >************************************************************************************** - -/// Recoge el identificador, clases CSS, atributos HTML y valores extra de un componente. -/// -/// Guarda estos valores con operaciones [`PropsOp`]. Cuando se renderiza usando -/// [`html!`](crate::html::html) se emite primero el identificador `id` (si existe), luego `class` -/// (si hay clases), después `style` (si hay declaraciones de estilo) y por último el resto de -/// atributos; normalmente se asignan al elemento raíz del componente. -/// -/// # Ejemplo -/// -/// En los ejemplos se omite la construcción explícita de `Context` (`let cx = Context::default();`) -/// para no distraer del resto del ejemplo. -/// -/// ```rust -/// # use pagetop::prelude::*; -/// # let cx = Context::default(); -/// 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.unpack(&cx)) { "Cargar" } -/// }; -/// -/// assert_eq!( -/// markup.into_string(), -/// r##""## -/// ); -/// ``` -/// -/// # Identificadores -/// -/// [`SetId`](PropsOp::SetId) (usando [`PropsOp::set_id()`]) normaliza el valor asignado al -/// identificador del componente: recorta espacios, convierte a minúsculas y sustituye los espacios -/// intermedios por `_`. -/// -/// ```rust -/// # use pagetop::prelude::*; -/// # let cx = Context::default(); -/// let props = Props::default().with_id("My Button"); -/// let markup = html! { button (props.unpack(&cx)) { "OK" } }; -/// assert_eq!(markup.into_string(), r#""#); -/// ``` -/// -/// [`EnsureId`](PropsOp::EnsureId) (usando [`PropsOp::ensure_id()`]) sólo asigna si no hay -/// identificador previo: -/// -/// ```rust -/// # use pagetop::prelude::*; -/// // Con `id` previo: `EnsureId` no tiene efecto. -/// let props = Props::default() -/// .with_id("explicit") -/// .with_prop(PropsOp::ensure_id("default")); -/// assert_eq!(props.get_id(), Some("explicit".to_string())); -/// -/// // Sin `id` previo: `EnsureId` asigna el valor. -/// let props = Props::default().with_prop(PropsOp::ensure_id("default")); -/// assert_eq!(props.get_id(), Some("default".to_string())); -/// ``` -/// -/// # Clases CSS -/// -/// ```rust -/// # use pagetop::prelude::*; -/// # let cx = Context::default(); -/// let props = Props::default() -/// .with_prop(PropsOp::add_classes("btn btn-primary")) -/// .with_prop(PropsOp::add_classes("active")) -/// .with_prop(PropsOp::replace_classes("btn-primary", "btn-secondary")); -/// -/// let markup = html! { button (props.unpack(&cx)) { "OK" } }; -/// assert_eq!(markup.into_string(), r#""#); -/// ``` -/// -/// # Estilos CSS -/// -/// Cada declaración se añade indicando una propiedad y su valor. Si la propiedad ya existe, -/// [`AddStyle`](PropsOp::AddStyle) sustituye su valor conservando la posición, sin duplicarla. -/// -/// ```rust -/// # use pagetop::prelude::*; -/// # let cx = Context::default(); -/// let props = Props::default() -/// .with_prop(PropsOp::add_style("color", "red")) -/// .with_prop(PropsOp::add_style("font-weight", "bold")) -/// .with_prop(PropsOp::add_style("color", "blue")) -/// .with_prop(PropsOp::remove_style("font-weight")); -/// -/// let markup = html! { button (props.unpack(&cx)) { "OK" } }; -/// assert_eq!(markup.into_string(), r#""#); -/// ``` -/// -/// # Atributos duplicados junto a `Props` -/// -/// Cuando el componente combina `(self.props().unpack(cx))` con un atributo del mismo nombre en el -/// mismo elemento (una clase, un `#id`, o `nombre=valor`), la macro [`html!`](crate::html::html) -/// evita automáticamente la duplicación. Recopila en tiempo de compilación los nombres de los -/// atributos del elemento y al renderizar se omiten los duplicados en tiempo de ejecución. No -/// depende del orden en que se escriban ni requiere ninguna acción del desarrollador. -/// -/// ```rust -/// # use pagetop::prelude::*; -/// # let cx = Context::default(); -/// let props = Props::default().with_prop(PropsOp::set("title", "de Props")); -/// -/// let markup = html! { span title="literal" (props.unpack(&cx)) { "OK" } }; -/// -/// // El atributo literal prevalece; `Props` omite su propio "title" en vez de duplicarlo. -/// assert_eq!(markup.into_string(), r#"OK"#); -/// ``` -/// -/// # Valores extra -/// -/// Las variantes [`SetExtra`](PropsOp::SetExtra) y [`RemoveExtra`](PropsOp::RemoveExtra), usando -/// [`PropsOp::set_extra()`] y [`PropsOp::remove_extra()`] respectivamente, permiten adjuntar -/// valores tipados a un `Props`. Son útiles para que temas y extensiones amplíen el comportamiento -/// de componentes ya existentes mediante traits con nuevos métodos que lean y escriban esos -/// valores. -/// -/// ```rust -/// # use pagetop::prelude::*; -/// const EXT_ENABLED: &str = "myext.enabled"; -/// const EXT_LABEL: &str = "myext.label"; -/// -/// let props = Props::default() -/// .with_prop(PropsOp::set_extra(EXT_ENABLED, true)) -/// .with_prop(PropsOp::set_extra(EXT_LABEL, "flotante".to_string())); -/// -/// assert!(props.extra_or(EXT_ENABLED, false)); -/// assert_eq!(props.extra_or(EXT_LABEL, String::new()), "flotante"); -/// -/// // Tipo incorrecto devuelve el valor por defecto indicado: -/// assert_eq!(props.extra_or(EXT_ENABLED, 0_u8), 0); -/// ``` -/// -/// Los valores extra no se emiten en el HTML al renderizar; son exclusivamente para uso interno de -/// temas y extensiones. -/// -/// # Integración en componentes -/// -/// El patrón recomendado es añadir un campo `props: Props` con su método *builder* delegado: -/// -/// ```rust,no_run -/// # use pagetop::prelude::*; -/// #[derive(AutoDefault, Clone, Getters)] -/// pub struct MyButton { -/// label: Lc, -/// props: Props, -/// } -/// -/// #[async_trait] -/// impl Component for MyButton { -/// fn new() -> Self { Self::default() } -/// -/// async fn prepare(&self, cx: &mut Context) -> Result { -/// Ok(html! { -/// button (self.props().unpack(cx)) { -/// (self.label().using(cx)) -/// } -/// }) -/// } -/// } -/// -/// #[builder_impl] -/// impl MyButton { -/// /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. -/// pub fn with_prop(mut self, op: PropsOp) -> Self { -/// self.props.alter_prop(op); -/// self -/// } -/// } -/// ``` -#[derive(AutoDefault, Clone, Debug)] -pub struct Props { - id: Option, - classes: Vec, - styles: Vec<(CowStr, CowStr)>, - attrs: Vec<(CowStr, CowStr)>, - extras: HashMap<&'static str, PropsExtra>, -} - -#[builder_impl] -impl Props { - /// Crea una colección con un primer atributo ya establecido. - pub fn new(name: impl Into, value: impl Into) -> 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) -> Self { - Self::default().with_prop(PropsOp::add_classes(classes)) - } - - // **< Props BUILDER >************************************************************************** - - /// Establece el identificador del componente; equivale a `with_prop(PropsOp::set_id(id))`. - pub fn with_id(mut self, id: impl Into) -> Self { - self.apply_id(id.into().as_ref()); - self - } - - /// Modifica el identificador, las clases, los atributos o los valores extra según la operación - /// indicada. El método recomendado para construir cada operación es usar los constructores de - /// [`PropsOp`]. - pub fn with_prop(mut self, op: PropsOp) -> Self { - match op { - PropsOp::SetId(value) => { - self.apply_id(value.as_ref()); - } - PropsOp::EnsureId(value) => { - if self.id.is_none() { - self.apply_id(value.as_ref()); - } - } - PropsOp::AddClasses(classes) => { - let Some(normalized) = util::normalize_ascii(classes.as_ref()) 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(classes.as_ref()) else { - return self; - }; - self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); - } - PropsOp::ReplaceClasses(old, new) => { - let Some(old) = util::normalize_ascii(old.as_ref()) else { - return self; - }; - let Some(new) = util::normalize_ascii(new.as_ref()) 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::ReplaceAllClasses(old, new) => { - let Some(old) = util::normalize_ascii(old.as_ref()) else { - return self; - }; - let Some(new) = util::normalize_ascii(new.as_ref()) else { - return self; - }; - if !self.has_all_classes(old.as_ref()) { - return self; - } - let mut pos = self.classes.len(); - for class in old.as_ref().split_ascii_whitespace() { - if let Some(replace_pos) = self.classes.iter().position(|c| c == class) { - self.classes.remove(replace_pos); - pos = pos.min(replace_pos); - } - } - self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); - } - PropsOp::RemoveClasses(classes) => { - let Some(normalized) = util::normalize_ascii(classes.as_ref()) else { - return self; - }; - self.classes.retain(|c| { - !normalized - .as_ref() - .split_ascii_whitespace() - .any(|r| r == c.as_str()) - }); - } - PropsOp::AddStyle(property, value) => { - self.set_style(property.as_ref(), value.as_ref()); - } - PropsOp::RemoveStyle(property) => { - self.remove_style(property.as_ref()); - } - PropsOp::Set(name, value) => { - if name.as_ref() == "id" { - self.apply_id(value.as_ref()); - } else if name.as_ref() == "class" { - if let Some(normalized) = util::normalize_ascii(value.as_ref()) { - self.classes.clear(); - self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); - } - } else if name.as_ref() == "style" { - self.styles.clear(); - self.parse_styles(value.as_ref()); - } else if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &name) { - self.attrs[pos].1 = value; - } else { - self.attrs.push((name, value)); - } - } - PropsOp::Rename(from, to) => { - if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &from) { - let (_, value) = self.attrs.remove(pos); - if !self.attrs.iter().any(|(k, _)| k == &to) { - self.attrs.push((to, value)); - } - } - } - PropsOp::Remove(name) => { - if name.as_ref() == "id" { - self.id = None; - } else if name.as_ref() == "class" { - self.classes.clear(); - } else if name.as_ref() == "style" { - self.styles.clear(); - } else { - self.attrs.retain(|(k, _)| k != &name); - } - } - PropsOp::SetExtra(key, extra) => { - self.extras.insert(key, extra); - } - PropsOp::RemoveExtra(key) => { - self.extras.remove(key); - } - PropsOp::FlexItem(placement) => { - placement.apply_to(self); - } - } - self - } - - // **< Props GETTERS >************************************************************************** - - /// Devuelve el identificador normalizado del componente, si existe. - #[inline] - pub fn get_id(&self) -> Option { - self.id.clone() - } - - /// Devuelve la lista de clases como cadena de texto, si hay clases definidas. - pub fn get_classes(&self) -> Option { - if self.classes.is_empty() { - None - } else { - Some(self.classes.join(" ")) - } - } - - /// Devuelve las declaraciones de estilo como cadena de texto (separadas por `"; "`), si hay - /// estilos definidos. - pub fn get_styles(&self) -> Option { - if self.styles.is_empty() { - None - } else { - Some( - self.styles - .iter() - .map(|(k, v)| format!("{k}: {v}")) - .collect::>() - .join("; "), - ) - } - } - - /// Devuelve el valor de la propiedad de estilo indicada, si existe. - pub fn get_style(&self, property: impl AsRef) -> Option { - let property = property.as_ref().trim().to_ascii_lowercase(); - self.styles - .iter() - .find(|(k, _)| k.as_ref() == property) - .map(|(_, v)| v.to_string()) - } - - /// Devuelve el valor del atributo indicado, si existe. - /// - /// Los nombres `"id"`, `"class"` y `"style"` son equivalentes a llamar a - /// [`get_id()`](Self::get_id), [`get_classes()`](Self::get_classes) y - /// [`get_styles()`](Self::get_styles) respectivamente. - pub fn get_prop(&self, name: impl AsRef) -> Option { - match name.as_ref() { - "id" => self.id.clone(), - "class" => self.get_classes(), - "style" => self.get_styles(), - name => self - .attrs - .iter() - .find(|(k, _)| k.as_ref() == name) - .map(|(_, v)| v.to_string()), - } - } - - /// Devuelve `true` si no hay ningún identificador definido. - #[inline] - pub fn is_id_empty(&self) -> bool { - self.id.is_none() - } - - /// Devuelve `true` si no hay ninguna clase definida. - #[inline] - pub fn is_classes_empty(&self) -> bool { - self.classes.is_empty() - } - - /// Devuelve `true` si no hay ningún estilo definido. - #[inline] - pub fn is_styles_empty(&self) -> bool { - self.styles.is_empty() - } - - /// Devuelve `true` si no hay ningún atributo adicional definido, sin tener en cuenta el - /// identificador, las clases ni los estilos. - #[inline] - pub fn is_attrs_empty(&self) -> bool { - self.attrs.is_empty() - } - - /// Devuelve `true` si no hay ningún identificador, clases, estilos o atributos adicionales - /// definidos, sin tener en cuenta los valores extra. - #[inline] - pub fn is_empty(&self) -> bool { - self.id.is_none() - && self.classes.is_empty() - && self.styles.is_empty() - && self.attrs.is_empty() - } - - /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente. - pub fn has_classes(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else { - return false; - }; - normalized - .as_ref() - .split_ascii_whitespace() - .any(|class| self.classes.iter().any(|c| c == class)) - } - - /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. - pub fn has_all_classes(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else { - return false; - }; - normalized - .as_ref() - .split_ascii_whitespace() - .all(|class| self.classes.iter().any(|c| c == class)) - } - - /// Recupera una referencia tipada al valor extra asociado a la clave `key`. - /// - /// Devuelve un [`Result`] que indica si la clave existe y si el tipo coincide: - /// - /// - `Ok(&T)` si la clave existe y el tipo coincide. El tipo `T` debe ser el mismo que se usó - /// al almacenar el valor con [`PropsOp::set_extra()`]. - /// - `Err(PropsError::ExtraNotFound)` si la clave no existe. - /// - `Err(PropsError::ExtraTypeMismatch)` si el tipo no coincide. - /// - /// ```rust - /// # use pagetop::prelude::*; - /// const EXT_COUNT: &str = "myext.count"; - /// const EXT_OTHER: &str = "myext.other"; - /// - /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_COUNT, 7_i32)); - /// - /// assert_eq!(*props.extra::(EXT_COUNT).unwrap(), 7); - /// assert_eq!( - /// props.extra::(EXT_OTHER), - /// Err(PropsError::ExtraNotFound { key: EXT_OTHER }) - /// ); - /// assert!(matches!( - /// props.extra::(EXT_COUNT), - /// Err(PropsError::ExtraTypeMismatch { .. }) - /// )); - /// ``` - pub fn extra(&self, key: &'static str) -> Result<&T, PropsError> { - let ev = self - .extras - .get(key) - .ok_or(PropsError::ExtraNotFound { key })?; - ev.value - .downcast_ref::() - .ok_or_else(|| PropsError::ExtraTypeMismatch { - key, - expected: TypeInfo::FullName.of::(), - found: ev.type_name, - }) - } - - /// Devuelve el valor extra clonado o el **valor `default`** si no existe o el tipo no coincide. - /// - /// ```rust - /// # use pagetop::prelude::*; - /// const EXT_FLAG: &str = "myext.flag"; - /// const EXT_OTHER: &str = "myext.other"; - /// - /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_FLAG, true)); - /// - /// assert!(props.extra_or(EXT_FLAG, false)); - /// assert!(!props.extra_or(EXT_OTHER, false)); - /// ``` - pub fn extra_or(&self, key: &'static str, default: T) -> T { - self.extra::(key).ok().cloned().unwrap_or(default) - } - - /// Devuelve el valor extra clonado o el **valor por defecto del tipo** si no existe o el tipo - /// no coincide. - /// - /// ```rust - /// # use pagetop::prelude::*; - /// const EXT_FLAG: &str = "myext.flag"; - /// const EXT_COUNT: &str = "myext.count"; - /// - /// let props = Props::default(); - /// - /// assert_eq!(props.extra_or_default::(EXT_FLAG), false); - /// assert_eq!(props.extra_or_default::(EXT_COUNT), 0); - /// ``` - pub fn extra_or_default(&self, key: &'static str) -> T { - self.extra::(key).ok().cloned().unwrap_or_default() - } - - /// Devuelve el valor extra clonado o el **valor evaluado por la función `f`** si no existe o el - /// tipo no coincide. - /// - /// ```rust - /// # use pagetop::prelude::*; - /// const EXT_LABEL: &str = "myext.label"; - /// - /// let props = Props::default(); - /// - /// let result = props.extra_or_else(EXT_LABEL, || "default".to_string()); - /// assert_eq!(result, "default"); - /// ``` - pub fn extra_or_else T>( - &self, - key: &'static str, - f: F, - ) -> T { - self.extra::(key).ok().cloned().unwrap_or_else(f) - } - - // **< Props RENDER >*************************************************************************** - - /// Extrae `Props` en la posición de atributos de [`html!`](crate::html::html) usando el - /// `Context` activo: `button (self.props().unpack(cx)) { ... }`. - /// - /// `Props` no implementa [`RenderAttrs`] directamente. Obliga a pasar siempre el `Context` - /// vigente en el punto donde se renderiza, aunque no lo necesite ningún atributo. - /// - /// ```rust - /// # use pagetop::prelude::*; - /// # let cx = Context::default(); - /// let props = Props::default().with_id("example"); - /// let markup = html! { button (props.unpack(&cx)) { "OK" } }; - /// assert_eq!(markup.into_string(), r#""#); - /// ``` - pub fn unpack<'a>(&'a self, cx: &'a Context) -> impl RenderAttrs + 'a { - PropsUnpack { props: self, cx } - } - - // **< Props PRIVATE >************************************************************************** - - fn apply_id(&mut self, id: &str) { - self.id = util::normalize_token(id); - } - - fn insert_classes<'a, I>(&mut self, classes: I, mut pos: usize) - where - I: IntoIterator, - { - 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; - } - } - } - - // Añade o sustituye una declaración "propiedad: valor". Si la propiedad ya existe, sustituye - // su valor conservando la posición; si no, la añade al final. Ignora la declaración si la - // propiedad o el valor quedan vacíos tras recortar espacios. No aplica `normalize_ascii`: ver - // la documentación de `PropsOp::AddStyle` sobre por qué los valores de estilo no se restringen - // a ASCII. - fn set_style(&mut self, property: &str, value: &str) { - let Some(property) = util::non_blank(property) else { - return; - }; - let property = property.to_ascii_lowercase(); - let Some(value) = util::non_blank(value) else { - return; - }; - if let Some(pos) = self.styles.iter().position(|(k, _)| k.as_ref() == property) { - self.styles[pos].1 = value.to_string().into(); - } else { - self.styles - .push((property.into(), value.to_string().into())); - } - } - - // Interpreta una cadena "propiedad: valor" separadas por ";" (igual que el atributo HTML - // `style`) y aplica cada declaración con `set_style`. Ignora las declaraciones sin ":". - fn parse_styles(&mut self, styles: &str) { - for style in Self::split_style_declarations(styles) { - let Some(style) = util::non_blank(style) else { - continue; - }; - let Some((property, value)) = style.split_once(':') else { - trace::debug!( - target = "Props::with_prop", - declaration = %style, - "Ignoring malformed style declaration (missing \":\")" - ); - continue; - }; - self.set_style(property, value); - } - } - - // Divide una cadena de declaraciones de estilo por ";", igual que `str::split(';')`, pero sin - // cortar dentro de paréntesis (`url(...)`) ni de cadenas entre comillas simples o dobles - // (`content: "a;b"`). No es un análisis CSS completo: no reconoce comentarios `/* ... */` ni - // comillas escapadas, y unos paréntesis o comillas sin cerrar arrastran el resto de la cadena - // a la última declaración. - fn split_style_declarations(styles: &str) -> Vec<&str> { - let mut depth = 0i32; - let mut quote = None; - let mut start = 0; - let mut parts = Vec::new(); - for (i, c) in styles.char_indices() { - if quote.is_none() && (c == '\'' || c == '"') { - quote = Some(c); - } else if quote == Some(c) { - quote = None; - } else if quote.is_none() && c == '(' { - depth += 1; - } else if quote.is_none() && c == ')' { - depth = (depth - 1).max(0); - } else if quote.is_none() && depth == 0 && c == ';' { - parts.push(&styles[start..i]); - start = i + 1; - } - } - parts.push(&styles[start..]); - parts - } - - // Elimina la propiedad de estilo indicada, si existe. - fn remove_style(&mut self, property: &str) { - let property = property.trim().to_ascii_lowercase(); - self.styles.retain(|(k, _)| k.as_ref() != property); - } -} - -impl Props { - // Escribe los atributos, omitiendo cualquiera que esté en `exclude` (recopilados por `html!` a - // partir de los atributos literales del elemento). Registra un `trace::debug!` por cada - // atributo duplicado, con la posición exacta del `html!` que lo produjo (propagado gracias a - // `#[track_caller]`, heredado desde `PropsUnpack::render_attrs_to()`) para facilitar la - // localización del problema. - #[track_caller] - fn write_attrs(&self, _cx: &Context, w: &mut String, exclude: &[&str]) { - if let Some(id) = self.id.as_deref() { - if exclude.contains(&"id") { - trace::debug!( - caller = %Location::caller(), - attribute = "id", - discarded = %id, - "Ignoring Props attribute already set as a literal on the same element" - ); - } else { - w.push_str(" id=\""); - let _ = write!(Escaper::new(w), "{}", id); - w.push('"'); - } - } - if let Some((first, rest)) = self.classes.split_first() { - if exclude.contains(&"class") { - trace::debug!( - caller = %Location::caller(), - attribute = "class", - discarded = %self.classes.join(" "), - id = %self.id.as_deref().unwrap_or(""), - "Ignoring Props attribute already set as a literal on the same element" - ); - } else { - w.push_str(" class=\""); - let _ = write!(Escaper::new(w), "{}", first); - for class in rest { - w.push(' '); - let _ = write!(Escaper::new(w), "{}", class); - } - w.push('"'); - } - } - if let Some((first, rest)) = self.styles.split_first() { - if exclude.contains(&"style") { - let discarded = self - .styles - .iter() - .map(|(property, value)| format!("{property}: {value}")) - .collect::>() - .join("; "); - trace::debug!( - caller = %Location::caller(), - attribute = "style", - discarded = %discarded, - id = %self.id.as_deref().unwrap_or(""), - "Ignoring Props attribute already set as a literal on the same element" - ); - } else { - w.push_str(" style=\""); - let _ = write!(Escaper::new(w), "{}: {}", first.0, first.1); - for (property, value) in rest { - w.push_str("; "); - let _ = write!(Escaper::new(w), "{}: {}", property, value); - } - w.push('"'); - } - } - for (name, value) in &self.attrs { - if exclude.contains(&name.as_ref()) { - trace::debug!( - caller = %Location::caller(), - attribute = %name, - discarded = %value, - id = %self.id.as_deref().unwrap_or(""), - "Ignoring Props attribute already set as a literal on the same element" - ); - continue; - } - w.push(' '); - let _ = write!(Escaper::new(w), "{}", name); - w.push_str("=\""); - let _ = write!(Escaper::new(w), "{}", value); - w.push('"'); - } - } -} - -// **< PropsUnpack >******************************************************************************** - -// Devuelto por `Props::unpack()`. -struct PropsUnpack<'a> { - props: &'a Props, - cx: &'a Context, -} - -#[doc(hidden)] -impl RenderAttrs for PropsUnpack<'_> { - #[track_caller] - fn render_attrs_to(&self, w: &mut String, exclude: &[&str]) { - self.props.write_attrs(self.cx, w, exclude); - } -} +mod definition; +pub use definition::Props; diff --git a/src/html/props/definition.rs b/src/html/props/definition.rs new file mode 100644 index 00000000..0fc3d504 --- /dev/null +++ b/src/html/props/definition.rs @@ -0,0 +1,809 @@ +use crate::core::TypeInfo; +use crate::core::component::Context; +use crate::html::maud::{Escaper, RenderAttrs}; +use crate::html::props::{PropsError, PropsExtra, PropsOp}; +use crate::{AutoDefault, CowStr, builder_impl, trace, util}; + +use std::collections::HashMap; +use std::fmt::Write; +use std::panic::Location; + +// **< Props >************************************************************************************** + +/// Recoge el identificador, clases CSS, atributos HTML y valores extra de un componente. +/// +/// Guarda estos valores con operaciones [`PropsOp`]. Cuando se renderiza usando +/// [`html!`](crate::html::html) se emite primero el identificador `id` (si existe), luego `class` +/// (si hay clases), después `style` (si hay declaraciones de estilo) y por último el resto de +/// atributos; normalmente se asignan al elemento raíz del componente. +/// +/// # Ejemplo +/// +/// Se omite la construcción explícita de `Context` (`let mut cx = Context::default();`) para no +/// distraer del resto del ejemplo. +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # let mut cx = Context::default(); +/// 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.unpack(&mut cx)) { "Cargar" } +/// }; +/// +/// assert_eq!( +/// markup.into_string(), +/// r##""## +/// ); +/// ``` +/// +/// # Identificadores +/// +/// [`SetId`](PropsOp::SetId) (usando [`PropsOp::set_id()`]) normaliza el valor asignado al +/// identificador del componente: recorta espacios, convierte a minúsculas y sustituye los espacios +/// intermedios por `_`. +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # let mut cx = Context::default(); +/// let props = Props::default().with_id("My Button"); +/// let markup = html! { button (props.unpack(&mut cx)) { "OK" } }; +/// assert_eq!(markup.into_string(), r#""#); +/// ``` +/// +/// [`EnsureId`](PropsOp::EnsureId) (usando [`PropsOp::ensure_id()`]) sólo asigna si no hay +/// identificador previo: +/// +/// ```rust +/// # use pagetop::prelude::*; +/// // Con `id` previo: `EnsureId` no tiene efecto. +/// let props = Props::default() +/// .with_id("explicit") +/// .with_prop(PropsOp::ensure_id("default")); +/// assert_eq!(props.get_id(), Some("explicit".to_string())); +/// +/// // Sin `id` previo: `EnsureId` asigna el valor. +/// let props = Props::default().with_prop(PropsOp::ensure_id("default")); +/// assert_eq!(props.get_id(), Some("default".to_string())); +/// ``` +/// +/// # Clases CSS +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # let mut cx = Context::default(); +/// let props = Props::default() +/// .with_prop(PropsOp::add_classes("btn btn-primary")) +/// .with_prop(PropsOp::add_classes("active")) +/// .with_prop(PropsOp::replace_classes("btn-primary", "btn-secondary")); +/// +/// let markup = html! { button (props.unpack(&mut cx)) { "OK" } }; +/// assert_eq!(markup.into_string(), r#""#); +/// ``` +/// +/// # Estilos CSS +/// +/// Cada declaración se añade indicando una propiedad y su valor. Si la propiedad ya existe, +/// [`AddStyle`](PropsOp::AddStyle) sustituye su valor conservando la posición, sin duplicarla. +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # let mut cx = Context::default(); +/// let props = Props::default() +/// .with_prop(PropsOp::add_style("color", "red")) +/// .with_prop(PropsOp::add_style("font-weight", "bold")) +/// .with_prop(PropsOp::add_style("color", "blue")) +/// .with_prop(PropsOp::remove_style("font-weight")); +/// +/// let markup = html! { button (props.unpack(&mut cx)) { "OK" } }; +/// assert_eq!(markup.into_string(), r#""#); +/// ``` +/// +/// # Atributos duplicados junto a `Props` +/// +/// Cuando el componente combina `(self.props().unpack(cx))` con un atributo del mismo nombre en el +/// mismo elemento (una clase, un `#id`, o `nombre=valor`), la macro [`html!`](crate::html::html) +/// evita automáticamente la duplicación. Recopila en tiempo de compilación los nombres de los +/// atributos del elemento y al renderizar se omiten los duplicados en tiempo de ejecución. No +/// depende del orden en que se escriban ni requiere ninguna acción del desarrollador. +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # let mut cx = Context::default(); +/// let props = Props::default().with_prop(PropsOp::set("title", "de Props")); +/// +/// let markup = html! { span title="literal" (props.unpack(&mut cx)) { "OK" } }; +/// +/// // El atributo literal prevalece; `Props` omite su propio "title" en vez de duplicarlo. +/// assert_eq!(markup.into_string(), r#"OK"#); +/// ``` +/// +/// # Valores extra +/// +/// Las variantes [`SetExtra`](PropsOp::SetExtra) y [`RemoveExtra`](PropsOp::RemoveExtra), usando +/// [`PropsOp::set_extra()`] y [`PropsOp::remove_extra()`] respectivamente, permiten adjuntar +/// valores tipados a un `Props`. Son útiles para que temas y extensiones amplíen el comportamiento +/// de componentes ya existentes mediante traits con nuevos métodos que lean y escriban esos +/// valores. +/// +/// ```rust +/// # use pagetop::prelude::*; +/// const EXT_ENABLED: &str = "myext.enabled"; +/// const EXT_LABEL: &str = "myext.label"; +/// +/// let props = Props::default() +/// .with_prop(PropsOp::set_extra(EXT_ENABLED, true)) +/// .with_prop(PropsOp::set_extra(EXT_LABEL, "flotante".to_string())); +/// +/// assert!(props.extra_or(EXT_ENABLED, false)); +/// assert_eq!(props.extra_or(EXT_LABEL, String::new()), "flotante"); +/// +/// // Tipo incorrecto devuelve el valor por defecto indicado: +/// assert_eq!(props.extra_or(EXT_ENABLED, 0_u8), 0); +/// ``` +/// +/// Los valores extra no se emiten en el HTML al renderizar; son exclusivamente para uso interno de +/// temas y extensiones. +/// +/// # Integración en componentes +/// +/// El patrón recomendado es añadir un campo `props: Props` con su método *builder* delegado: +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// #[derive(AutoDefault, Clone, Getters)] +/// pub struct MyButton { +/// label: Lc, +/// props: Props, +/// } +/// +/// #[async_trait] +/// impl Component for MyButton { +/// fn new() -> Self { Self::default() } +/// +/// async fn prepare(&self, cx: &mut Context) -> Result { +/// Ok(html! { +/// button (self.props().unpack(cx)) { +/// (self.label().using(cx)) +/// } +/// }) +/// } +/// } +/// +/// #[builder_impl] +/// impl MyButton { +/// /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. +/// pub fn with_prop(mut self, op: PropsOp) -> Self { +/// self.props.alter_prop(op); +/// self +/// } +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug)] +pub struct Props { + id: Option, + classes: Vec, + styles: Vec<(CowStr, CowStr)>, + attrs: Vec<(CowStr, CowStr)>, + extras: HashMap<&'static str, PropsExtra>, +} + +#[builder_impl] +impl Props { + /// Crea una colección con un primer atributo ya establecido. + pub fn new(name: impl Into, value: impl Into) -> 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) -> Self { + Self::default().with_prop(PropsOp::add_classes(classes)) + } + + // **< Props BUILDER >************************************************************************** + + /// Establece el identificador del componente; equivale a `with_prop(PropsOp::set_id(id))`. + pub fn with_id(mut self, id: impl Into) -> Self { + self.apply_id(id.into().as_ref()); + self + } + + /// Modifica el identificador, las clases, los atributos o los valores extra según la operación + /// indicada. El método recomendado para construir cada operación es usar los constructores de + /// [`PropsOp`]. + pub fn with_prop(mut self, op: PropsOp) -> Self { + match op { + PropsOp::SetId(value) => { + self.apply_id(value.as_ref()); + } + PropsOp::EnsureId(value) => { + if self.id.is_none() { + self.apply_id(value.as_ref()); + } + } + PropsOp::AddClasses(classes) => { + let Some(normalized) = util::normalize_ascii(classes.as_ref()) 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(classes.as_ref()) else { + return self; + }; + self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); + } + PropsOp::ReplaceClasses(old, new) => { + let Some(old) = util::normalize_ascii(old.as_ref()) else { + return self; + }; + let Some(new) = util::normalize_ascii(new.as_ref()) 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::ReplaceAllClasses(old, new) => { + let Some(old) = util::normalize_ascii(old.as_ref()) else { + return self; + }; + let Some(new) = util::normalize_ascii(new.as_ref()) else { + return self; + }; + if !self.has_all_classes(old.as_ref()) { + return self; + } + let mut pos = self.classes.len(); + for class in old.as_ref().split_ascii_whitespace() { + if let Some(replace_pos) = self.classes.iter().position(|c| c == class) { + self.classes.remove(replace_pos); + pos = pos.min(replace_pos); + } + } + self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); + } + PropsOp::RemoveClasses(classes) => { + let Some(normalized) = util::normalize_ascii(classes.as_ref()) else { + return self; + }; + self.classes.retain(|c| { + !normalized + .as_ref() + .split_ascii_whitespace() + .any(|r| r == c.as_str()) + }); + } + PropsOp::AddStyle(property, value) => { + self.set_style(property.as_ref(), value.as_ref()); + } + PropsOp::RemoveStyle(property) => { + self.remove_style(property.as_ref()); + } + PropsOp::Set(name, value) => { + if name.as_ref() == "id" { + self.apply_id(value.as_ref()); + } else if name.as_ref() == "class" { + if let Some(normalized) = util::normalize_ascii(value.as_ref()) { + self.classes.clear(); + self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); + } + } else if name.as_ref() == "style" { + self.styles.clear(); + self.parse_styles(value.as_ref()); + } else if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &name) { + self.attrs[pos].1 = value; + } else { + self.attrs.push((name, value)); + } + } + PropsOp::Rename(from, to) => { + if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &from) { + let (_, value) = self.attrs.remove(pos); + if !self.attrs.iter().any(|(k, _)| k == &to) { + self.attrs.push((to, value)); + } + } + } + PropsOp::Remove(name) => { + if name.as_ref() == "id" { + self.id = None; + } else if name.as_ref() == "class" { + self.classes.clear(); + } else if name.as_ref() == "style" { + self.styles.clear(); + } else { + self.attrs.retain(|(k, _)| k != &name); + } + } + PropsOp::SetExtra(key, extra) => { + self.extras.insert(key, extra); + } + PropsOp::RemoveExtra(key) => { + self.extras.remove(key); + } + PropsOp::FlexItem(placement) => { + placement.apply_to(self); + } + } + self + } + + // **< Props GETTERS >************************************************************************** + + /// Devuelve el identificador normalizado del componente, si existe. + #[inline] + pub fn get_id(&self) -> Option { + self.id.clone() + } + + /// Devuelve la lista de clases como cadena de texto, si hay clases definidas. + pub fn get_classes(&self) -> Option { + if self.classes.is_empty() { + None + } else { + Some(self.classes.join(" ")) + } + } + + /// Devuelve las declaraciones de estilo como cadena de texto (separadas por `"; "`), si hay + /// estilos definidos. + pub fn get_styles(&self) -> Option { + if self.styles.is_empty() { + None + } else { + Some( + self.styles + .iter() + .map(|(k, v)| format!("{k}: {v}")) + .collect::>() + .join("; "), + ) + } + } + + /// Devuelve el valor de la propiedad de estilo indicada, si existe. + pub fn get_style(&self, property: impl AsRef) -> Option { + let property = property.as_ref().trim().to_ascii_lowercase(); + self.styles + .iter() + .find(|(k, _)| k.as_ref() == property) + .map(|(_, v)| v.to_string()) + } + + /// Devuelve el valor del atributo indicado, si existe. + /// + /// Los nombres `"id"`, `"class"` y `"style"` son equivalentes a llamar a + /// [`get_id()`](Self::get_id), [`get_classes()`](Self::get_classes) y + /// [`get_styles()`](Self::get_styles) respectivamente. + pub fn get_prop(&self, name: impl AsRef) -> Option { + match name.as_ref() { + "id" => self.id.clone(), + "class" => self.get_classes(), + "style" => self.get_styles(), + name => self + .attrs + .iter() + .find(|(k, _)| k.as_ref() == name) + .map(|(_, v)| v.to_string()), + } + } + + /// Devuelve `true` si no hay ningún identificador definido. + #[inline] + pub fn is_id_empty(&self) -> bool { + self.id.is_none() + } + + /// Devuelve `true` si no hay ninguna clase definida. + #[inline] + pub fn is_classes_empty(&self) -> bool { + self.classes.is_empty() + } + + /// Devuelve `true` si no hay ningún estilo definido. + #[inline] + pub fn is_styles_empty(&self) -> bool { + self.styles.is_empty() + } + + /// Devuelve `true` si no hay ningún atributo adicional definido, sin tener en cuenta el + /// identificador, las clases ni los estilos. + #[inline] + pub fn is_attrs_empty(&self) -> bool { + self.attrs.is_empty() + } + + /// Devuelve `true` si no hay ningún identificador, clases, estilos o atributos adicionales + /// definidos, sin tener en cuenta los valores extra. + #[inline] + pub fn is_empty(&self) -> bool { + self.id.is_none() + && self.classes.is_empty() + && self.styles.is_empty() + && self.attrs.is_empty() + } + + /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente. + pub fn has_classes(&self, classes: impl AsRef) -> bool { + let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else { + return false; + }; + normalized + .as_ref() + .split_ascii_whitespace() + .any(|class| self.classes.iter().any(|c| c == class)) + } + + /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. + pub fn has_all_classes(&self, classes: impl AsRef) -> bool { + let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else { + return false; + }; + normalized + .as_ref() + .split_ascii_whitespace() + .all(|class| self.classes.iter().any(|c| c == class)) + } + + /// Recupera una referencia tipada al valor extra asociado a la clave `key`. + /// + /// Devuelve un [`Result`] que indica si la clave existe y si el tipo coincide: + /// + /// - `Ok(&T)` si la clave existe y el tipo coincide. El tipo `T` debe ser el mismo que se usó + /// al almacenar el valor con [`PropsOp::set_extra()`]. + /// - `Err(PropsError::ExtraNotFound)` si la clave no existe. + /// - `Err(PropsError::ExtraTypeMismatch)` si el tipo no coincide. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_COUNT: &str = "myext.count"; + /// const EXT_OTHER: &str = "myext.other"; + /// + /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_COUNT, 7_i32)); + /// + /// assert_eq!(*props.extra::(EXT_COUNT).unwrap(), 7); + /// assert_eq!( + /// props.extra::(EXT_OTHER), + /// Err(PropsError::ExtraNotFound { key: EXT_OTHER }) + /// ); + /// assert!(matches!( + /// props.extra::(EXT_COUNT), + /// Err(PropsError::ExtraTypeMismatch { .. }) + /// )); + /// ``` + pub fn extra(&self, key: &'static str) -> Result<&T, PropsError> { + let ev = self + .extras + .get(key) + .ok_or(PropsError::ExtraNotFound { key })?; + ev.value + .downcast_ref::() + .ok_or_else(|| PropsError::ExtraTypeMismatch { + key, + expected: TypeInfo::FullName.of::(), + found: ev.type_name, + }) + } + + /// Devuelve el valor extra clonado o el **valor `default`** si no existe o el tipo no coincide. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_FLAG: &str = "myext.flag"; + /// const EXT_OTHER: &str = "myext.other"; + /// + /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_FLAG, true)); + /// + /// assert!(props.extra_or(EXT_FLAG, false)); + /// assert!(!props.extra_or(EXT_OTHER, false)); + /// ``` + pub fn extra_or(&self, key: &'static str, default: T) -> T { + self.extra::(key).ok().cloned().unwrap_or(default) + } + + /// Devuelve el valor extra clonado o el **valor por defecto del tipo** si no existe o el tipo + /// no coincide. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_FLAG: &str = "myext.flag"; + /// const EXT_COUNT: &str = "myext.count"; + /// + /// let props = Props::default(); + /// + /// assert_eq!(props.extra_or_default::(EXT_FLAG), false); + /// assert_eq!(props.extra_or_default::(EXT_COUNT), 0); + /// ``` + pub fn extra_or_default(&self, key: &'static str) -> T { + self.extra::(key).ok().cloned().unwrap_or_default() + } + + /// Devuelve el valor extra clonado o el **valor evaluado por la función `f`** si no existe o el + /// tipo no coincide. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_LABEL: &str = "myext.label"; + /// + /// let props = Props::default(); + /// + /// let result = props.extra_or_else(EXT_LABEL, || "default".to_string()); + /// assert_eq!(result, "default"); + /// ``` + pub fn extra_or_else T>( + &self, + key: &'static str, + f: F, + ) -> T { + self.extra::(key).ok().cloned().unwrap_or_else(f) + } + + // **< Props RENDER >*************************************************************************** + + /// Extrae `Props` en la posición de atributos de [`html!`] usando el `Context` activo: + /// `button (self.props().unpack(cx)) { ... }`. + /// + /// `Props` no implementa [`RenderAttrs`] directamente. Obliga a pasar siempre el `Context` + /// vigente en el punto donde se renderiza, aunque no lo necesite ningún atributo propio. Recibe + /// `&mut Context` porque aquí, en el momento de extraer los atributos, es donde se resuelve + /// [`FlexItem`]: las clases que devuelve se añaden a las del propio componente al escribir el + /// atributo `class`. + /// + /// Si el propio elemento actúa además como contenedor [`Flex`], utiliza [`unpack_with_flex()`] + /// en su lugar. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// # let mut cx = Context::default(); + /// let props = Props::default().with_id("example"); + /// let markup = html! { button (props.unpack(&mut cx)) { "OK" } }; + /// assert_eq!(markup.into_string(), r#""#); + /// ``` + /// + /// [`html!`]: crate::html::html + /// [`Flex`]: crate::html::flex::Flex + /// [`FlexItem`]: crate::html::flex::FlexItem + /// [`unpack_with_flex()`]: Self::unpack_with_flex + pub fn unpack<'a>(&'a self, cx: &mut Context) -> impl RenderAttrs + 'a { + PropsUnpack { + props: self, + classes: self.flex_item.apply(cx), + } + } + + /// Igual que [`unpack()`], pero además resuelve `flex` con el posicionamiento [`Flex`]. + /// + /// A diferencia de [`FlexItem`] (que se acumula con [`PropsOp::FlexItem`] porque cualquier + /// componente ajeno puede necesitarlo sin tener un campo propio para ello), `Flex` sólo tiene + /// sentido en los contenedores que ya declaran su propio campo `flex: Flex` (`Container`, + /// `Navbar`...): se les pasa aquí directamente, ya resuelto (`self.flex()`), sin pasar por + /// `PropsOp`. + /// + /// [`unpack()`]: Self::unpack + /// [`Flex`]: crate::html::flex::Flex + /// [`FlexItem`]: crate::html::flex::FlexItem + /// [`PropsOp::FlexItem`]: crate::html::props::PropsOp::FlexItem + pub fn unpack_with_flex<'a>(&'a self, cx: &mut Context, flex: Flex) -> impl RenderAttrs + 'a { + PropsUnpack { + props: self, + classes: util::join_pair!(flex.apply(cx), " ", self.flex_item.apply(cx)), + } + } + + // **< Props PRIVATE >************************************************************************** + + fn apply_id(&mut self, id: &str) { + self.id = util::normalize_token(id); + } + + fn insert_classes<'a, I>(&mut self, classes: I, mut pos: usize) + where + I: IntoIterator, + { + 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; + } + } + } + + // Añade o sustituye una declaración "propiedad: valor". Si la propiedad ya existe, sustituye + // su valor conservando la posición; si no, la añade al final. Ignora la declaración si la + // propiedad o el valor quedan vacíos tras recortar espacios. No aplica `normalize_ascii`: ver + // la documentación de `PropsOp::AddStyle` sobre por qué los valores de estilo no se restringen + // a ASCII. + fn set_style(&mut self, property: &str, value: &str) { + let Some(property) = util::non_blank(property) else { + return; + }; + let property = property.to_ascii_lowercase(); + let Some(value) = util::non_blank(value) else { + return; + }; + if let Some(pos) = self.styles.iter().position(|(k, _)| k.as_ref() == property) { + self.styles[pos].1 = value.to_string().into(); + } else { + self.styles + .push((property.into(), value.to_string().into())); + } + } + + // Interpreta una cadena "propiedad: valor" separadas por ";" (igual que el atributo HTML + // `style`) y aplica cada declaración con `set_style`. Ignora las declaraciones sin ":". + fn parse_styles(&mut self, styles: &str) { + for style in Self::split_style_declarations(styles) { + let Some(style) = util::non_blank(style) else { + continue; + }; + let Some((property, value)) = style.split_once(':') else { + trace::debug!( + target = "Props::with_prop", + declaration = %style, + "Ignoring malformed style declaration (missing \":\")" + ); + continue; + }; + self.set_style(property, value); + } + } + + // Divide una cadena de declaraciones de estilo por ";", igual que `str::split(';')`, pero sin + // cortar dentro de paréntesis (`url(...)`) ni de cadenas entre comillas simples o dobles + // (`content: "a;b"`). No es un análisis CSS completo: no reconoce comentarios `/* ... */` ni + // comillas escapadas, y unos paréntesis o comillas sin cerrar arrastran el resto de la cadena + // a la última declaración. + fn split_style_declarations(styles: &str) -> Vec<&str> { + let mut depth = 0i32; + let mut quote = None; + let mut start = 0; + let mut parts = Vec::new(); + for (i, c) in styles.char_indices() { + if quote.is_none() && (c == '\'' || c == '"') { + quote = Some(c); + } else if quote == Some(c) { + quote = None; + } else if quote.is_none() && c == '(' { + depth += 1; + } else if quote.is_none() && c == ')' { + depth = (depth - 1).max(0); + } else if quote.is_none() && depth == 0 && c == ';' { + parts.push(&styles[start..i]); + start = i + 1; + } + } + parts.push(&styles[start..]); + parts + } + + // Elimina la propiedad de estilo indicada, si existe. + fn remove_style(&mut self, property: &str) { + let property = property.trim().to_ascii_lowercase(); + self.styles.retain(|(k, _)| k.as_ref() != property); + } +} + +impl Props { + // Escribe los atributos, omitiendo cualquiera que esté en `exclude` (recopilados por `html!` a + // partir de los atributos literales del elemento). Registra un `trace::debug!` por cada + // atributo duplicado, con la posición exacta del `html!` que lo produjo (propagado gracias a + // `#[track_caller]`, heredado desde `PropsUnpack::render_attrs_to()`) para facilitar la + // localización del problema. + #[track_caller] + fn write_attrs(&self, classes: &str, w: &mut String, exclude: &[&str]) { + if let Some(id) = self.id.as_deref() { + if exclude.contains(&"id") { + trace::debug!( + caller = %Location::caller(), + attribute = "id", + discarded = %id, + "Ignoring Props attribute already set as a literal on the same element" + ); + } else { + w.push_str(" id=\""); + let _ = write!(Escaper::new(w), "{}", id); + w.push('"'); + } + } + // Clases propias del componente más las que aplica `Props::unpack()`/`unpack_with_flex()`. + let mut all_classes: Vec<&str> = self.classes.iter().map(String::as_str).collect(); + all_classes.extend(classes.split_ascii_whitespace()); + if let Some((first, rest)) = all_classes.split_first() { + if exclude.contains(&"class") { + trace::debug!( + caller = %Location::caller(), + attribute = "class", + discarded = %all_classes.join(" "), + id = %self.id.as_deref().unwrap_or(""), + "Ignoring Props attribute already set as a literal on the same element" + ); + } else { + w.push_str(" class=\""); + let _ = write!(Escaper::new(w), "{}", first); + for class in rest { + w.push(' '); + let _ = write!(Escaper::new(w), "{}", class); + } + w.push('"'); + } + } + if let Some((first, rest)) = self.styles.split_first() { + if exclude.contains(&"style") { + let discarded = self + .styles + .iter() + .map(|(property, value)| format!("{property}: {value}")) + .collect::>() + .join("; "); + trace::debug!( + caller = %Location::caller(), + attribute = "style", + discarded = %discarded, + id = %self.id.as_deref().unwrap_or(""), + "Ignoring Props attribute already set as a literal on the same element" + ); + } else { + w.push_str(" style=\""); + let _ = write!(Escaper::new(w), "{}: {}", first.0, first.1); + for (property, value) in rest { + w.push_str("; "); + let _ = write!(Escaper::new(w), "{}: {}", property, value); + } + w.push('"'); + } + } + for (name, value) in &self.attrs { + if exclude.contains(&name.as_ref()) { + trace::debug!( + caller = %Location::caller(), + attribute = %name, + discarded = %value, + id = %self.id.as_deref().unwrap_or(""), + "Ignoring Props attribute already set as a literal on the same element" + ); + continue; + } + w.push(' '); + let _ = write!(Escaper::new(w), "{}", name); + w.push_str("=\""); + let _ = write!(Escaper::new(w), "{}", value); + w.push('"'); + } + } +} + +// **< PropsUnpack >******************************************************************************** + +// Devuelto por `Props::unpack()`/`Props::unpack_with_flex()`. `classes` son las clases resueltas +// por `FlexItem::apply()`/`Flex::apply()` (desde el propio `unpack*()` usando el `&mut Context`), +// pendientes sólo de añadir a las del componente (ver `Props::write_attrs()`). +struct PropsUnpack<'a> { + props: &'a Props, + classes: String, +} + +#[doc(hidden)] +impl RenderAttrs for PropsUnpack<'_> { + #[track_caller] + fn render_attrs_to(&self, w: &mut String, exclude: &[&str]) { + self.props.write_attrs(&self.classes, w, exclude); + } +} diff --git a/src/html/props/error.rs b/src/html/props/error.rs new file mode 100644 index 00000000..dad64876 --- /dev/null +++ b/src/html/props/error.rs @@ -0,0 +1,18 @@ +use thiserror::Error; + +/// Errores de acceso a valores extra de [`Props`](crate::html::props::Props). +#[derive(Debug, PartialEq, Eq, Error)] +pub enum PropsError { + /// La clave no existe. Incluye la clave (`key`). + #[error("extra \"{key}\" not found")] + ExtraNotFound { key: &'static str }, + /// La clave existe pero el tipo solicitado no coincide con el almacenado. Incluye la clave + /// (`key`), tipo esperado (`expected`) y tipo realmente encontrado (`found`) para facilitar el + /// diagnóstico. + #[error("type mismatch for extra \"{key}\": expected \"{expected}\", found \"{found}\"")] + ExtraTypeMismatch { + key: &'static str, + expected: &'static str, + found: &'static str, + }, +} diff --git a/src/html/props/extra.rs b/src/html/props/extra.rs new file mode 100644 index 00000000..34059917 --- /dev/null +++ b/src/html/props/extra.rs @@ -0,0 +1,30 @@ +use std::any::Any; +use std::fmt; +use std::sync::Arc; + +/// Encapsula un valor tipado extra para almacenar en [`Props`](crate::html::props::Props). +/// +/// Internamente usa [`Arc`] para que [`Props`](crate::html::props::Props) pueda implementar +/// [`Clone`] sin requerir que los valores almacenados sean clonables. El nombre del tipo +/// almacenado permite generar mensajes de error precisos. +pub struct PropsExtra { + // `pub(super)`: `PropsOp::set_extra()` (en el módulo hermano `op`) construye este valor + // directamente con un literal de struct. + pub(super) value: Arc, + pub(super) type_name: &'static str, +} + +impl Clone for PropsExtra { + fn clone(&self) -> Self { + Self { + value: Arc::clone(&self.value), + type_name: self.type_name, + } + } +} + +impl fmt::Debug for PropsExtra { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "<{}>", self.type_name) + } +} diff --git a/src/html/props/op.rs b/src/html/props/op.rs new file mode 100644 index 00000000..d67c2e78 --- /dev/null +++ b/src/html/props/op.rs @@ -0,0 +1,272 @@ +use crate::CowStr; +use crate::core::TypeInfo; +use crate::html::flex::FlexItem; +use crate::html::props::extra::PropsExtra; + +use std::any::Any; +use std::sync::Arc; + +/// Operaciones sobre el identificador, clases CSS, atributos HTML y valores extra en +/// [`Props`](crate::html::props::Props). +/// +/// Cada variante lleva los datos necesarios para ejecutarse. El método recomendado para usarlas es +/// recurrir a los constructores asociados como [`set_id()`](Self::set_id), +/// [`add_classes()`](Self::add_classes), [`set()`](Self::set), etc. +/// +/// Las variantes `*Id` operan sobre el atributo `id` del componente. Cuando se usa `"id"` como +/// nombre de atributo en `Set`, el valor se normaliza igual que [`SetId`](Self::SetId). +/// +/// Las variantes `*Classes` gestionan la lista de clases CSS. Además, `Set("class", ...)` +/// reemplaza la lista completa de clases y `Remove("class")` la vacía. +/// +/// Las variantes `*Style` gestionan las declaraciones de estilos para el atributo `style`, con una +/// propiedad cada vez. Además, `Set("style", ...)` reemplaza la lista completa de estilos y +/// `Remove("style")` la vacía. +/// +/// Las variantes [`Set`](Self::Set) y [`Remove`](Self::Remove) son operaciones de propósito +/// general. `Set` añade o reemplaza cualquier atributo HTML por nombre y valor, y `Remove` lo +/// elimina. Los atributos `id`, `class` y `style` tienen semántica especial documentada en estas +/// variantes. +/// +/// [`Rename`](Self::Rename) cambia el nombre de un atributo genérico conservando su valor; no +/// reconoce `"id"`, `"class"` ni `"style"` como origen ni destino. Está pensada para que un tema +/// traduzca a su propio vocabulario los atributos que un componente ya expone, sin que éste tenga +/// que conocer ningún tema en concreto. +/// +/// Las variantes `*Extra` permiten añadir valores tipados usando una clave. Están pensadas para +/// ampliar el comportamiento de componentes ya existentes. Como no es posible añadir campos a la +/// estructura de un componente ya definido, temas y extensiones pueden definir un trait con nuevos +/// métodos que leen y escriben valores extra en [`Props`](crate::html::props::Props). Esos valores +/// se interpretan como si +/// fueran valores internos del componente para tomar decisiones durante el renderizado. +/// +/// Finalmente, [`FlexItem`](Self::FlexItem) aplica un posicionamiento Flexbox a nivel de ítem sobre +/// cualquier componente. +#[derive(Clone, Debug)] +pub enum PropsOp { + /// Establece el identificador del componente normalizando el valor: recorta espacios, convierte + /// a minúsculas y sustituye los espacios intermedios por `_`. Si el resultado es vacío, elimina + /// el identificador. + SetId(CowStr), + /// Establece el identificador del componente **sólo si aún no hay ninguno definido**. Aplica la + /// misma normalización que [`SetId`](Self::SetId); si el resultado es vacío, la operación no + /// tiene efecto. + EnsureId(CowStr), + /// Añade la clase o clases que no existan al final de la lista. La operación se ignora si el + /// valor contiene caracteres no ASCII. + AddClasses(CowStr), + /// 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 más** clases del primer valor por las clases indicadas en el segundo + /// valor, insertando las nuevas en la posición de la primera clase a sustituir encontrada, con + /// independencia del orden en que aparecen en el primer valor. Las que no existan se ignoran. + /// Si **ninguna** de las clases a sustituir existe, la operación no tiene efecto y no se + /// inserta nada. Se ignora si alguno de los dos valores contiene caracteres no ASCII. + ReplaceClasses(CowStr, CowStr), + /// A diferencia de [`ReplaceClasses`](Self::ReplaceClasses), exige que **todas** las clases del + /// primer valor estén presentes, independientemente de su orden; si falta una sola, la + /// operación no tiene efecto: ninguna clase se elimina ni se inserta. Si todas están presentes, + /// las sustituye por las clases indicadas en el segundo valor, insertando las nuevas en la + /// posición de la primera clase a sustituir encontrada. Se ignora si alguno de los dos valores + /// contiene caracteres no ASCII. + ReplaceAllClasses(CowStr, CowStr), + /// Elimina la clase o clases indicadas de la lista. La operación se ignora si el valor contiene + /// caracteres no ASCII. + RemoveClasses(CowStr), + /// Añade una declaración de estilo (propiedad, valor) o sustituye su valor si la propiedad ya + /// existe, conservando su posición; si no, se añade al final. A diferencia de las clases, el + /// valor admite caracteres no ASCII (p. ej. `content`, `font-family`) y distingue mayúsculas y + /// minúsculas. El nombre de la propiedad se normaliza a minúsculas. Si la propiedad o el valor + /// quedan vacíos tras recortar espacios, la operación se ignora. + AddStyle(CowStr, CowStr), + /// Elimina la propiedad de estilo indicada, si existe. + RemoveStyle(CowStr), + /// Añade un atributo o sustituye su valor si ya existe. + /// + /// Usar `"id"` como nombre de atributo aplica al valor la misma normalización que + /// [`SetId`](Self::SetId). + /// + /// Usar `"class"` como nombre de atributo reemplaza la lista completa de clases por las nuevas + /// indicadas; la operación se ignora si el valor contiene caracteres no ASCII. + /// + /// Usar `"style"` como nombre de atributo reemplaza la lista completa de estilos por los nuevos + /// indicados, interpretando el valor como declaraciones `"propiedad: valor"` separadas por `;` + /// (igual que el propio atributo `style` HTML). El separador `;` respeta paréntesis y comillas, + /// tal que valores como una *data URI* (`background: url(data:image/png;base64,...)`) o una + /// cadena con `;` (`content: "a;b"`) se interpretan correctamente. En cualquier caso, se + /// recomienda usar [`PropsOp::add_style()`](Self::add_style) para declarar estilos. + Set(CowStr, CowStr), + /// Si el primer atributo (origen) existe, lo renombra al segundo (destino), conservando su + /// valor; si el destino ya tiene su propio valor, se respeta sin sobrescribir y sólo se elimina + /// el origen. Si el origen no existe, la operación no tiene efecto. + /// + /// Sólo actúa sobre atributos genéricos, por lo que los nombres `"id"`, `"class"` y `"style"` + /// no se reconocen como origen ni destino. + Rename(CowStr, CowStr), + /// Elimina el atributo indicado. Usar `"id"` elimina el identificador; usar `"class"` vacía la + /// lista de clases; y usar `"style"` vacía la lista de estilos. + Remove(CowStr), + /// Almacena un valor extra tipado asociado a la clave indicada. Si ya existe uno con esa clave, + /// lo reemplaza. + SetExtra(&'static str, PropsExtra), + /// Elimina el valor extra asociado a la clave indicada, si existe. + RemoveExtra(&'static str), + /// Aplica un posicionamiento [`FlexItem`] a un componente particular en un contenedor [`Flex`]. + /// Añade directamente sus estilos Flexbox al propio componente, sin usar clases CSS. + /// + /// Existe como variante de `PropsOp`, y no como método builder de un componente, porque las + /// propiedades de un ítem Flexbox tienen sentido sobre **cualquier** componente que pueda + /// añadirse como hijo de un contenedor Flex (por ejemplo `Button`, `Nav`, un componente de + /// terceros, incluso otro componente que sea, a su vez, un contenedor Flex para sus propios + /// hijos). + /// + /// No existe una variante equivalente `PropsOp::Flex` para el componente contenedor. No hace + /// falta porque los componentes contenedores, como `Container` o `Navbar`, ofrecen su propio + /// `with_flex()` tipado y con introspección (p. ej. [`Container::flex()`]). + /// + /// [`Flex`]: crate::html::flex::Flex + /// [`Container::flex()`]: crate::base::component::Container::flex + FlexItem(FlexItem), +} + +impl PropsOp { + /// Crea la variante [`SetId`](Self::SetId) con el identificador indicado. + pub fn set_id(id: impl Into) -> Self { + Self::SetId(id.into()) + } + + /// Crea la variante [`EnsureId`](Self::EnsureId) con el identificador indicado. + pub fn ensure_id(id: impl Into) -> Self { + Self::EnsureId(id.into()) + } + + /// Crea la variante [`AddClasses`](Self::AddClasses) con la clase o clases indicadas. + pub fn add_classes(classes: impl Into) -> Self { + Self::AddClasses(classes.into()) + } + + /// Crea la variante [`PrependClasses`](Self::PrependClasses) con la clase o clases indicadas. + pub fn prepend_classes(classes: impl Into) -> Self { + 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())); + /// + /// // Basta con que exista alguna clase de `old` para aplicar el reemplazo. + /// let props = Props::classes("btn primary") + /// .with_prop(PropsOp::replace_classes("primary secondary", "danger")); + /// assert_eq!(props.get_classes(), Some("btn danger".to_string())); + /// ``` + pub fn replace_classes(old: impl Into, new: impl Into) -> Self { + Self::ReplaceClasses(old.into(), new.into()) + } + + /// Crea la variante [`ReplaceAllClasses`](Self::ReplaceAllClasses) con las clases a sustituir + /// (`old`) y las nuevas clases (`new`). + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let props = Props::classes("btn primary") + /// .with_prop(PropsOp::replace_all_classes("btn primary", "btn danger")); + /// assert_eq!(props.get_classes(), Some("btn danger".to_string())); + /// + /// // Si falta una sola clase de `old`, no hay reemplazo. + /// let props = Props::classes("btn primary") + /// .with_prop(PropsOp::replace_all_classes("primary secondary", "danger")); + /// assert_eq!(props.get_classes(), Some("btn primary".to_string())); + /// ``` + pub fn replace_all_classes(old: impl Into, new: impl Into) -> Self { + Self::ReplaceAllClasses(old.into(), new.into()) + } + + /// Crea la variante [`RemoveClasses`](Self::RemoveClasses) con la clase o clases indicadas. + pub fn remove_classes(classes: impl Into) -> Self { + Self::RemoveClasses(classes.into()) + } + + /// Crea la variante [`AddStyle`](Self::AddStyle) con la propiedad y el valor de estilo + /// indicados. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let props = Props::default() + /// .with_prop(PropsOp::add_style("color", "red")) + /// .with_prop(PropsOp::add_style("font-weight", "bold")) + /// .with_prop(PropsOp::add_style("color", "blue")); + /// assert_eq!(props.get_styles(), Some("color: blue; font-weight: bold".to_string())); + /// ``` + pub fn add_style(property: impl Into, value: impl Into) -> Self { + Self::AddStyle(property.into(), value.into()) + } + + /// Crea la variante [`RemoveStyle`](Self::RemoveStyle) para la propiedad de estilo indicada. + pub fn remove_style(property: impl Into) -> Self { + Self::RemoveStyle(property.into()) + } + + /// Crea la variante [`Set`](Self::Set) con nombre y valor del atributo. + pub fn set(name: impl Into, value: impl Into) -> Self { + Self::Set(name.into(), value.into()) + } + + /// Crea la variante [`Rename`](Self::Rename) para renombrar `from` a `to`. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let props = Props::new("data-dialog-toggle", "modal") + /// .with_prop(PropsOp::rename("data-dialog-toggle", "data-bs-toggle")); + /// assert_eq!(props.get_prop("data-bs-toggle"), Some("modal".to_string())); + /// assert_eq!(props.get_prop("data-dialog-toggle"), None); + /// + /// // Si el destino tiene su propio valor no se sobrescribe, sólo se elimina el origen. + /// let props = Props::new("data-bs-toggle", "collapse") + /// .with_prop(PropsOp::set("data-dialog-toggle", "modal")) + /// .with_prop(PropsOp::rename("data-dialog-toggle", "data-bs-toggle")); + /// assert_eq!(props.get_prop("data-bs-toggle"), Some("collapse".to_string())); + /// assert_eq!(props.get_prop("data-dialog-toggle"), None); + /// ``` + pub fn rename(from: impl Into, to: impl Into) -> Self { + Self::Rename(from.into(), to.into()) + } + + /// Crea la variante [`Remove`](Self::Remove) para el atributo indicado. + pub fn remove(name: impl Into) -> Self { + Self::Remove(name.into()) + } + + /// Crea la variante [`SetExtra`](Self::SetExtra) con la clave y el valor indicados. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// const EXT_SIZE: &str = "myext.size"; + /// let props = Props::default().with_prop(PropsOp::set_extra(EXT_SIZE, 42_u32)); + /// assert_eq!(props.extra_or(EXT_SIZE, 0_u32), 42); + /// ``` + pub fn set_extra(key: &'static str, value: T) -> Self { + Self::SetExtra( + key, + PropsExtra { + value: Arc::new(value), + type_name: TypeInfo::FullName.of::(), + }, + ) + } + + /// Crea la variante [`RemoveExtra`](Self::RemoveExtra) para la clave indicada. + pub fn remove_extra(key: &'static str) -> Self { + Self::RemoveExtra(key) + } + + /// Crea la variante [`FlexItem`](Self::FlexItem) con el posicionamiento indicado. + pub fn flex_item(placement: FlexItem) -> Self { + Self::FlexItem(placement) + } +} diff --git a/src/response/page.rs b/src/response/page.rs index 42a40e6a..fde52ec2 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -247,7 +247,7 @@ impl Page { head { (head) } - body (self.body_props().unpack(&self.context)) { + body (self.body_props().clone().unpack(&mut self.context)) { (body) } } diff --git a/tests/component_container.rs b/tests/component_container.rs index 30c2c9fd..36943788 100644 --- a/tests/component_container.rs +++ b/tests/component_container.rs @@ -49,92 +49,97 @@ async fn without_flex_no_style_attribute_is_added() { #[pagetop::test] async fn default_flex_adds_only_display_flex() { + let mut cx = Context::default(); let mut container = Container::new() - .with_flex(Flex::row()) + .with_flex(Flex::new()) .with_child(Lc::n("x")); - let html = container - .render(&mut Context::default()) - .await - .into_string(); + let html = container.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(html.contains(r#"style="display: flex""#)); + assert!(html.contains(r#"class="_flex_""#)); + assert!(assets.contains("_flex_{display:flex}")); } #[pagetop::test] async fn column_direction_adds_flex_direction_style() { + let mut cx = Context::default(); let mut container = Container::new() - .with_flex(Flex::column()) + .with_flex(Flex::new().with_direction(flex::Direction::Column)) .with_child(Lc::n("x")); - let html = container - .render(&mut Context::default()) - .await - .into_string(); + let html = container.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(html.contains("display: flex")); - assert!(html.contains("flex-direction: column")); + assert!(html.contains("_flex_")); + assert!(html.contains("_flex-direction_column_")); + assert!(assets.contains("_flex_{display:flex}")); + assert!(assets.contains("_flex-direction_column_{flex-direction:column}")); } #[pagetop::test] async fn wrap_justify_and_align_add_their_matching_styles() { + let mut cx = Context::default(); let mut container = Container::new() .with_flex( - Flex::row() + Flex::new() .with_wrap(flex::Behavior::Wrap) .with_justify(flex::ContentJustify::Center) .with_align(flex::Align::Center) .with_align_content(flex::AlignContent::SpaceBetween), ) .with_child(Lc::n("x")); - let html = container - .render(&mut Context::default()) - .await - .into_string(); + let html = container.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(html.contains("flex-wrap: wrap")); - assert!(html.contains("justify-content: center")); - assert!(html.contains("align-items: center")); - assert!(html.contains("align-content: space-between")); + assert!(html.contains("_flex-wrap_wrap_")); + assert!(html.contains("_flex-justify_center_")); + assert!(html.contains("_flex-align-items_center_")); + assert!(html.contains("_flex-align-content_space-between_")); + assert!(assets.contains("_flex-wrap_wrap_{flex-wrap:wrap}")); + assert!(assets.contains("_flex-justify_center_{justify-content:center}")); + assert!(assets.contains("_flex-align-items_center_{align-items:center}")); + assert!(assets.contains("_flex-align-content_space-between_{align-content:space-between}")); } #[pagetop::test] async fn gap_both_adds_a_single_gap_style() { + let mut cx = Context::default(); let mut container = Container::new() - .with_flex(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5)))) + .with_flex(Flex::new().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5)))) .with_child(Lc::n("x")); - let html = container - .render(&mut Context::default()) - .await - .into_string(); + let html = container.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(html.contains("gap: 0.5rem")); + assert!(html.contains("_flex-gap_0_5rem_")); + assert!(assets.contains("_flex-gap_0_5rem_{gap:0.5rem}")); } #[pagetop::test] async fn gap_distinct_adds_row_and_column_gap_styles() { + let mut cx = Context::default(); let mut container = Container::new() - .with_flex(Flex::row().with_gap(flex::Gap::Distinct { + .with_flex(Flex::new().with_gap(flex::Gap::Distinct { row: UnitValue::Px(4), column: UnitValue::Px(8), })) .with_child(Lc::n("x")); - let html = container - .render(&mut Context::default()) - .await - .into_string(); + let html = container.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(html.contains("row-gap: 4px")); - assert!(html.contains("column-gap: 8px")); + assert!(html.contains("_flex-row-gap_4px_")); + assert!(html.contains("_flex-column-gap_8px_")); + assert!(assets.contains("_flex-row-gap_4px_{row-gap:4px}")); + assert!(assets.contains("_flex-column-gap_8px_{column-gap:8px}")); } #[pagetop::test] async fn gap_none_adds_no_gap_style() { + let mut cx = Context::default(); let mut container = Container::new() - .with_flex(Flex::row()) + .with_flex(Flex::new()) .with_child(Lc::n("x")); - let html = container - .render(&mut Context::default()) - .await - .into_string(); + let html = container.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(!html.contains("gap:")); + assert!(!html.contains("gap")); + assert!(!assets.contains("gap")); } diff --git a/tests/component_navbar.rs b/tests/component_navbar.rs index 08279db2..f4f15bbe 100644 --- a/tests/component_navbar.rs +++ b/tests/component_navbar.rs @@ -17,7 +17,7 @@ async fn is_not_rendered_when_empty() { #[pagetop::test] async fn nav_root_class_is_unaffected_by_content_flex() { let mut navbar = Navbar::simple() - .with_flex(Flex::row().with_justify(flex::ContentJustify::End)) + .with_flex(Flex::new().with_justify(flex::ContentJustify::End)) .with_item(navbar::Item::nav(one_link_nav())); let html = navbar.render(&mut Context::default()).await.into_string(); @@ -37,32 +37,42 @@ async fn without_flex_content_area_has_no_style_attribute() { #[pagetop::test] async fn flex_adds_its_styles_to_the_content_area() { + let mut cx = Context::default(); let mut navbar = Navbar::simple() - .with_flex(Flex::row().with_justify(flex::ContentJustify::End)) + .with_flex(Flex::new().with_justify(flex::ContentJustify::End)) .with_item(navbar::Item::nav(one_link_nav())); - let html = navbar.render(&mut Context::default()).await.into_string(); + let html = navbar.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(html.contains(r#"class="navbar-content""#)); - assert!(html.contains("display: flex")); - assert!(html.contains("justify-content: flex-end")); + assert!(html.contains("navbar-content")); + assert!(html.contains("_flex_")); + assert!(html.contains("_flex-justify_flex-end_")); + assert!(assets.contains("_flex_{display:flex}")); + assert!(assets.contains("_flex-justify_flex-end_{justify-content:flex-end}")); } #[pagetop::test] async fn flex_gap_adds_a_style_to_the_content_area() { + let mut cx = Context::default(); let mut navbar = Navbar::simple() - .with_flex(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5)))) + .with_flex(Flex::new().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5)))) .with_item(navbar::Item::nav(one_link_nav())); - let html = navbar.render(&mut Context::default()).await.into_string(); + let html = navbar.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(html.contains("gap: 0.5rem")); + assert!(html.contains("_flex-gap_0_5rem_")); + assert!(assets.contains("_flex-gap_0_5rem_{gap:0.5rem}")); } // **< Navbar + FlexItem::push_end >**************************************************************** #[pagetop::test] async fn push_end_adds_an_automatic_start_margin() { - let mut nav = one_link_nav().with_prop(FlexItem::push_end()); - let html = nav.render(&mut Context::default()).await.into_string(); + let mut cx = Context::default(); + let mut nav = one_link_nav().with_prop(FlexItem::push_end().into()); + let html = nav.render(&mut cx).await.into_string(); + let assets = cx.render_assets().into_string(); - assert!(html.contains(r#"style="margin-inline-start: auto""#)); + assert!(html.contains("_flex-item-offset_auto_")); + assert!(assets.contains("_flex-item-offset_auto_{margin-inline-start:auto}")); } diff --git a/tests/html_props.rs b/tests/html_props.rs index 5b70472c..01c5e26a 100644 --- a/tests/html_props.rs +++ b/tests/html_props.rs @@ -4,9 +4,9 @@ use pagetop::prelude::*; #[pagetop::test] async fn props_default_renders_nothing() { - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (Props::default().unpack(&cx)) {} }.into_string(), + html! { span (Props::default().unpack(&mut cx)) {} }.into_string(), "" ); } @@ -45,9 +45,9 @@ async fn props_set_replaces_existing_value() { async fn props_set_does_not_create_duplicate_key() { // Reassigning the same key must replace the value, not add a duplicate entry. let p = Props::new("key", "v1").with_prop(PropsOp::set("key", "v2")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } @@ -57,9 +57,9 @@ 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")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } @@ -85,9 +85,9 @@ async fn props_remove_nonexistent_key_is_noop() { #[pagetop::test] async fn props_renders_nothing_after_removing_last_attr() { let p = Props::new("only", "one").with_prop(PropsOp::remove("only")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), "" ); } @@ -97,9 +97,9 @@ async fn props_renders_nothing_after_removing_last_attr() { #[pagetop::test] async fn props_escapes_ampersand_and_angle_brackets_in_value() { let p = Props::new("data-info", "a&bd"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } @@ -107,9 +107,9 @@ async fn props_escapes_ampersand_and_angle_brackets_in_value() { #[pagetop::test] async fn props_escapes_double_quotes_in_value() { let p = Props::new("data-label", r#"say "hello""#); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } @@ -120,9 +120,9 @@ async fn props_escapes_double_quotes_in_value() { async fn props_empty_in_html_macro_produces_no_attributes() { // An empty Props must not emit even an extra blank space. let p = Props::default(); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) { "x" } }.into_string(), + html! { button (p.unpack(&mut cx)) { "x" } }.into_string(), "" ); } @@ -130,9 +130,9 @@ async fn props_empty_in_html_macro_produces_no_attributes() { #[pagetop::test] async fn props_single_attr_in_html_macro() { let p = Props::new("hx-get", "/api"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) { "Load" } }.into_string(), + html! { button (p.unpack(&mut cx)) { "Load" } }.into_string(), r#""# ); } @@ -142,9 +142,9 @@ 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")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) {} }.into_string(), + html! { button (p.unpack(&mut cx)) {} }.into_string(), r##""## ); } @@ -153,9 +153,9 @@ async fn props_multiple_attrs_preserve_order_in_html_macro() { async fn props_alongside_class_and_id_in_html_macro() { // The unpack is always emitted after class and id, regardless of the order they are written in. let p = Props::new("hx-get", "/api"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button #mybtn .btn (p.unpack(&cx)) { "Go" } }.into_string(), + html! { button #mybtn .btn (p.unpack(&mut cx)) { "Go" } }.into_string(), r#""# ); } @@ -163,9 +163,9 @@ async fn props_alongside_class_and_id_in_html_macro() { #[pagetop::test] async fn props_alongside_named_attr_renders_after_it() { let p = Props::new("hx-get", "/api"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button type="button" (p.unpack(&cx)) {} }.into_string(), + html! { button type="button" (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } @@ -176,31 +176,31 @@ async fn props_combined_via_chaining_instead_of_multiple_splices() { // is a compile error); values from separate sources are combined by chaining `with_prop()` // on one `Props`, not by splicing two of them. let p = Props::new("hx-get", "/api").with_prop(PropsOp::set("hx-swap", "outerHTML")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) {} }.into_string(), + html! { button (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } #[pagetop::test] async fn props_inline_construction_in_html_macro() { - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (Props::new("hx-get", "/api").unpack(&cx)) { "Go" } }.into_string(), + html! { button (Props::new("hx-get", "/api").unpack(&mut cx)) { "Go" } }.into_string(), r#""# ); } #[pagetop::test] async fn props_conditional_expression_in_html_macro() { - let cx = Context::default(); + let mut cx = Context::default(); for (active, expected) in [ (true, r#""#), (false, ""), ] { let markup = html! { - button (if active { Props::new("hx-get", "/api") } else { Props::default() }.unpack(&cx)) { "x" } + button (if active { Props::new("hx-get", "/api") } else { Props::default() }.unpack(&mut cx)) { "x" } }; assert_eq!(markup.into_string(), expected); } @@ -219,9 +219,9 @@ async fn props_id_collision_with_literal_omits_props_id() { // A literal `#id` on the element takes precedence; `Props`'s own id is silently omitted instead // of producing a duplicate `id` attribute. let p = Props::default().with_id("from-props"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { div #fixed (p.unpack(&cx)) {} }.into_string(), + html! { div #fixed (p.unpack(&mut cx)) {} }.into_string(), r#"
"# ); } @@ -229,9 +229,9 @@ async fn props_id_collision_with_literal_omits_props_id() { #[pagetop::test] async fn props_class_collision_with_literal_omits_props_classes() { let p = Props::classes("from-props-a from-props-b"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { div.fixed (p.unpack(&cx)) {} }.into_string(), + html! { div.fixed (p.unpack(&mut cx)) {} }.into_string(), r#"
"# ); } @@ -241,9 +241,9 @@ async fn props_style_collision_with_literal_omits_props_styles() { let p = Props::default() .with_prop(PropsOp::add_style("color", "red")) .with_prop(PropsOp::add_style("font-weight", "bold")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { div style="color: blue" (p.unpack(&cx)) {} }.into_string(), + html! { div style="color: blue" (p.unpack(&mut cx)) {} }.into_string(), r#"
"# ); } @@ -251,9 +251,9 @@ async fn props_style_collision_with_literal_omits_props_styles() { #[pagetop::test] async fn props_named_attr_collision_with_literal_omits_props_value() { let p = Props::default().with_prop(PropsOp::set("title", "from-props")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span title="literal" (p.unpack(&cx)) {} }.into_string(), + html! { span title="literal" (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } @@ -321,9 +321,9 @@ async fn get_prop_id_matches_get_id() { async fn props_hx_target_value_with_hash_renders_correctly() { // Regression: r#"..."# used to close prematurely when it found `"#list"`. let p = Props::new("hx-target", "#list"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) {} }.into_string(), + html! { button (p.unpack(&mut cx)) {} }.into_string(), r##""## ); } @@ -331,9 +331,9 @@ async fn props_hx_target_value_with_hash_renders_correctly() { #[pagetop::test] async fn props_with_empty_value_renders_attr_with_empty_value() { let p = Props::new("data-expanded", ""); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } @@ -348,9 +348,9 @@ async fn props_chained_set_and_remove_yields_expected_state() { assert_eq!(p.get_prop("a"), Some("updated".to_string())); assert_eq!(p.get_prop("b"), None); assert_eq!(p.get_prop("c"), Some("3".to_string())); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } @@ -359,9 +359,9 @@ async fn props_chained_set_and_remove_yields_expected_state() { async fn props_with_empty_attr_name_renders_without_validation() { // Documented behavior: names are not validated; the resulting HTML is not standard. let p = Props::new("", "val"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } diff --git a/tests/html_props_classes.rs b/tests/html_props_classes.rs index dc512652..3517955c 100644 --- a/tests/html_props_classes.rs +++ b/tests/html_props_classes.rs @@ -299,9 +299,9 @@ async fn get_prop_class_matches_get_classes() { #[pagetop::test] async fn props_classes_renders_class_attribute() { let p = Props::classes("btn btn-primary"); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) { "OK" } }.into_string(), + html! { button (p.unpack(&mut cx)) { "OK" } }.into_string(), r#""# ); } @@ -309,9 +309,9 @@ async fn props_classes_renders_class_attribute() { #[pagetop::test] async fn props_classes_can_be_extended_with_add_classes() { let p = Props::classes("btn").with_prop(PropsOp::add_classes("active")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) { "OK" } }.into_string(), + html! { button (p.unpack(&mut cx)) { "OK" } }.into_string(), r#""# ); } diff --git a/tests/html_props_extras.rs b/tests/html_props_extras.rs index 0ae47005..69d30de3 100644 --- a/tests/html_props_extras.rs +++ b/tests/html_props_extras.rs @@ -113,9 +113,9 @@ async fn extras_not_emitted_in_html() { let props = Props::default() .with_prop(PropsOp::set_extra("ext.flag", true)) .with_prop(PropsOp::add_classes("btn")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (props.unpack(&cx)) { "OK" } }.into_string(), + html! { button (props.unpack(&mut cx)) { "OK" } }.into_string(), r#""# ); } diff --git a/tests/html_props_flex_item.rs b/tests/html_props_flex_item.rs index c228df16..369d326a 100644 --- a/tests/html_props_flex_item.rs +++ b/tests/html_props_flex_item.rs @@ -2,93 +2,121 @@ use pagetop::prelude::*; #[pagetop::test] async fn default_flex_item_adds_nothing() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item(FlexItem::new())); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); - assert_eq!(props.get_classes(), None); - assert_eq!(props.get_styles(), None); + assert_eq!(html, ""); + assert!(cx.render_assets().into_string().is_empty()); } #[pagetop::test] async fn grow_adds_flex_grow_style() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new().with_grow(flex::ItemGrow::Is1), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!(props.get_styles(), Some("flex-grow: 1".to_string())); + assert!(html.contains(r#"class="_flex-item-grow_1_""#)); + assert!(assets.contains("_flex-item-grow_1_{flex-grow:1}")); } #[pagetop::test] async fn shrink_adds_flex_shrink_style() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new().with_shrink(flex::ItemShrink::Is0), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!(props.get_styles(), Some("flex-shrink: 0".to_string())); + assert!(html.contains(r#"class="_flex-item-shrink_0_""#)); + assert!(assets.contains("_flex-item-shrink_0_{flex-shrink:0}")); } #[pagetop::test] async fn align_self_adds_matching_style() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new().with_align_self(flex::ItemAlign::Center), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!(props.get_styles(), Some("align-self: center".to_string())); + assert!(html.contains(r#"class="_flex-item-align_center_""#)); + assert!(assets.contains("_flex-item-align_center_{align-self:center}")); } #[pagetop::test] async fn order_adds_matching_style() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new().with_order(flex::ItemOrder::First), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!(props.get_styles(), Some("order: -129".to_string())); + assert!(html.contains(r#"class="_flex-item-order_-129_""#)); + assert!(assets.contains("_flex-item-order_-129_{order:-129}")); } #[pagetop::test] async fn size_percent_adds_flex_basis_style() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new().with_size(flex::ItemSize::Percent33), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!(props.get_styles(), Some("flex-basis: 33.3333%".to_string())); + assert!(html.contains(r#"class="_flex-item-basis_33_3333pct_""#)); + assert!(assets.contains("_flex-item-basis_33_3333pct_{flex-basis:33.3333%}")); } #[pagetop::test] async fn size_custom_adds_flex_basis_style() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new().with_size(flex::ItemSize::Custom(UnitValue::Zero)), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!(props.get_classes(), None); - assert_eq!(props.get_styles(), Some("flex-basis: 0".to_string())); + assert!(html.contains(r#"class="_flex-item-basis_0_""#)); + assert!(assets.contains("_flex-item-basis_0_{flex-basis:0}")); } #[pagetop::test] async fn offset_percent_adds_margin_inline_start_style() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new().with_offset(flex::ItemOffset::Percent33), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!( - props.get_styles(), - Some("margin-inline-start: 33.3333%".to_string()) - ); + assert!(html.contains(r#"class="_flex-item-offset_33_3333pct_""#)); + assert!(assets.contains("_flex-item-offset_33_3333pct_{margin-inline-start:33.3333%}")); } #[pagetop::test] async fn offset_custom_adds_margin_inline_start_style() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new().with_offset(flex::ItemOffset::Custom(UnitValue::Px(16))), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!( - props.get_styles(), - Some("margin-inline-start: 16px".to_string()) - ); + assert!(html.contains(r#"class="_flex-item-offset_16px_""#)); + assert!(assets.contains("_flex-item-offset_16px_{margin-inline-start:16px}")); } #[pagetop::test] async fn combines_several_facets_in_one_call() { + let mut cx = Context::default(); let props = Props::default().with_prop(PropsOp::flex_item( FlexItem::new() .with_grow(flex::ItemGrow::Is1) @@ -98,22 +126,31 @@ async fn combines_several_facets_in_one_call() { .with_size(flex::ItemSize::Custom(UnitValue::Zero)) .with_offset(flex::ItemOffset::Percent10), )); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!( - props.get_styles(), - Some( - "flex-grow: 1; flex-shrink: 0; align-self: flex-start; order: 2; flex-basis: 0; \ - margin-inline-start: 10%" - .to_string() - ) - ); - assert_eq!(props.get_classes(), None); + assert!(html.contains("_flex-item-grow_1_")); + assert!(html.contains("_flex-item-shrink_0_")); + assert!(html.contains("_flex-item-align_flex-start_")); + assert!(html.contains("_flex-item-order_2_")); + assert!(html.contains("_flex-item-basis_0_")); + assert!(html.contains("_flex-item-offset_10pct_")); + assert!(assets.contains("_flex-item-grow_1_{flex-grow:1}")); + assert!(assets.contains("_flex-item-shrink_0_{flex-shrink:0}")); + assert!(assets.contains("_flex-item-align_flex-start_{align-self:flex-start}")); + assert!(assets.contains("_flex-item-order_2_{order:2}")); + assert!(assets.contains("_flex-item-basis_0_{flex-basis:0}")); + assert!(assets.contains("_flex-item-offset_10pct_{margin-inline-start:10%}")); } #[pagetop::test] async fn from_flex_item_for_props_op() { + let mut cx = Context::default(); let item = FlexItem::new().with_grow(flex::ItemGrow::Is1); let props = Props::default().with_prop(item.into()); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let assets = cx.render_assets().into_string(); - assert_eq!(props.get_styles(), Some("flex-grow: 1".to_string())); + assert!(html.contains(r#"class="_flex-item-grow_1_""#)); + assert!(assets.contains("_flex-item-grow_1_{flex-grow:1}")); } diff --git a/tests/html_props_styles.rs b/tests/html_props_styles.rs index 153ae85b..eef8d6c6 100644 --- a/tests/html_props_styles.rs +++ b/tests/html_props_styles.rs @@ -275,9 +275,9 @@ async fn props_styles_renders_style_attribute() { let p = Props::default() .with_prop(PropsOp::add_style("color", "red")) .with_prop(PropsOp::add_style("font-weight", "bold")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) { "OK" } }.into_string(), + html! { button (p.unpack(&mut cx)) { "OK" } }.into_string(), r#""# ); } @@ -289,9 +289,9 @@ async fn props_styles_render_after_class_and_before_other_attrs() { .with_prop(PropsOp::add_classes("btn")) .with_prop(PropsOp::add_style("color", "red")) .with_prop(PropsOp::set("data-x", "1")); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { button (p.unpack(&cx)) { "OK" } }.into_string(), + html! { button (p.unpack(&mut cx)) { "OK" } }.into_string(), r#""# ); } @@ -299,9 +299,9 @@ async fn props_styles_render_after_class_and_before_other_attrs() { #[pagetop::test] async fn props_styles_escapes_double_quotes_in_value() { let p = Props::default().with_prop(PropsOp::add_style("content", r#""hi""#)); - let cx = Context::default(); + let mut cx = Context::default(); assert_eq!( - html! { span (p.unpack(&cx)) {} }.into_string(), + html! { span (p.unpack(&mut cx)) {} }.into_string(), r#""# ); } diff --git a/tests/html_responsives.rs b/tests/html_responsives.rs index 561eb45e..49b2b774 100644 --- a/tests/html_responsives.rs +++ b/tests/html_responsives.rs @@ -256,7 +256,7 @@ async fn render_zero_min_width_breakpoint_has_no_media_query() { let cx = Context::default(); let mut r = ResponsiveStyles::new(); r.add_style(Breakpoint::Xs, "col", "flex-basis", "100%"); - assert_eq!(r.render(&cx).into_string(), ".col { flex-basis: 100% }"); + assert_eq!(r.render(&cx).into_string(), ".col{flex-basis:100%}"); } #[pagetop::test] @@ -264,7 +264,7 @@ async fn render_none_breakpoint_has_no_media_query() { let cx = Context::default(); let mut r = ResponsiveStyles::new(); r.add_style(None, "col", "flex-basis", "100%"); - assert_eq!(r.render(&cx).into_string(), ".col { flex-basis: 100% }"); + assert_eq!(r.render(&cx).into_string(), ".col{flex-basis:100%}"); } #[pagetop::test] @@ -275,7 +275,7 @@ async fn render_none_comes_before_every_breakpoint() { r.add_style(None, "row", "display", "flex"); assert_eq!( r.render(&cx).into_string(), - ".row { display: flex }@media (min-width: 768px) { .col { flex-basis: 50% } }" + ".row{display:flex}@media(min-width:768px){.col{flex-basis:50%}}" ); } @@ -287,7 +287,7 @@ async fn render_non_zero_breakpoint_wraps_in_media_query() { r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); assert_eq!( r.render(&cx).into_string(), - "@media (min-width: 768px) { .col { flex-basis: 50% } }" + "@media(min-width:768px){.col{flex-basis:50%}}" ); } @@ -299,7 +299,7 @@ async fn render_groups_multiple_properties_in_the_same_rule() { r.add_style(Breakpoint::Md, "col", "margin-inline-start", "0"); assert_eq!( r.render(&cx).into_string(), - "@media (min-width: 768px) { .col { flex-basis: 50%; margin-inline-start: 0 } }" + "@media(min-width:768px){.col{flex-basis:50%;margin-inline-start:0}}" ); } @@ -311,7 +311,7 @@ async fn render_concatenates_rules_of_different_selectors_in_the_same_breakpoint r.add_style(Breakpoint::Md, "row", "display", "flex"); assert_eq!( r.render(&cx).into_string(), - "@media (min-width: 768px) { .col { flex-basis: 50% }.row { display: flex } }" + "@media(min-width:768px){.col{flex-basis:50%}.row{display:flex}}" ); } @@ -322,7 +322,7 @@ async fn render_converts_multiple_classes_into_a_compound_selector() { r.add_style(Breakpoint::Md, "foo bar", "color", "red"); assert_eq!( r.render(&cx).into_string(), - "@media (min-width: 768px) { .foo.bar { color: red } }" + "@media(min-width:768px){.foo.bar{color:red}}" ); } @@ -335,9 +335,9 @@ async fn render_orders_breakpoints_mobile_first_regardless_of_insertion_order() r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); assert_eq!( r.render(&cx).into_string(), - ".col { flex-basis: 100% }\ - @media (min-width: 768px) { .col { flex-basis: 50% } }\ - @media (min-width: 992px) { .col { flex-basis: 33% } }" + ".col{flex-basis:100%}\ + @media(min-width:768px){.col{flex-basis:50%}}\ + @media(min-width:992px){.col{flex-basis:33%}}" ); } @@ -357,9 +357,9 @@ async fn render_has_no_line_breaks() { async fn context_add_responsive_style_feeds_responsives() { let cx = Context::default().with_assets(AssetsOp::AddResponsiveStyle( Some(Breakpoint::Md), - "col", - "flex-basis", - "50%", + "col".into(), + "flex-basis".into(), + "50%".into(), )); assert_eq!( cx.responsive_styles().get_styles(Breakpoint::Md, "col"), @@ -372,15 +372,15 @@ async fn context_add_responsive_style_accumulates_across_calls() { let cx = Context::default() .with_assets(AssetsOp::AddResponsiveStyle( Some(Breakpoint::Md), - "col", - "flex-basis", - "50%", + "col".into(), + "flex-basis".into(), + "50%".into(), )) .with_assets(AssetsOp::AddResponsiveStyle( Some(Breakpoint::Md), - "col", - "margin-inline-start", - "0", + "col".into(), + "margin-inline-start".into(), + "0".into(), )); assert_eq!( cx.responsive_styles().get_styles(Breakpoint::Md, "col"), @@ -399,13 +399,13 @@ async fn context_default_has_no_responsive_styles() { async fn render_assets_includes_style_tag_with_responsive_styles() { let mut cx = Context::default().with_assets(AssetsOp::AddResponsiveStyle( Some(Breakpoint::Xs), - "col", - "flex-basis", - "100%", + "col".into(), + "flex-basis".into(), + "100%".into(), )); assert_eq!( cx.render_assets().into_string(), - "" + "" ); }