From 8577ca8a592224516581e62492d307dfb4715cb3 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Tue, 8 Sep 2026 07:28:50 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(pagetop):=20Props::unpack?= =?UTF-8?q?()=20exige=20&mut=20Context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extensions/pagetop-htmx/src/hx.rs | 4 +- src/base/component/breadcrumb/crumb.rs | 2 +- src/base/component/table/column.rs | 2 +- src/html/logo.rs | 2 +- src/html/props/definition.rs | 88 ++++++++++++++++++------- src/response/page.rs | 2 +- tests/component_container.rs | 87 ++++++++++++------------ tests/component_navbar.rs | 34 ++++++---- tests/html_props.rs | 88 ++++++++++++------------- tests/html_props_classes.rs | 8 +-- tests/html_props_extras.rs | 4 +- tests/html_props_flex_item.rs | 91 ++++++++++++++++++-------- tests/html_props_styles.rs | 12 ++-- tests/html_responsives.rs | 46 ++++++------- 14 files changed, 280 insertions(+), 190 deletions(-) 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/definition.rs b/src/html/props/definition.rs index fb503c09..0fc3d504 100644 --- a/src/html/props/definition.rs +++ b/src/html/props/definition.rs @@ -19,18 +19,18 @@ use std::panic::Location; /// /// # Ejemplo /// -/// En los ejemplos se omite la construcción explícita de `Context` (`let cx = Context::default();`) -/// para no distraer del resto del 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 cx = Context::default(); +/// # 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(&cx)) { "Cargar" } +/// button (props.unpack(&mut cx)) { "Cargar" } /// }; /// /// assert_eq!( @@ -47,9 +47,9 @@ use std::panic::Location; /// /// ```rust /// # use pagetop::prelude::*; -/// # let cx = Context::default(); +/// # let mut cx = Context::default(); /// let props = Props::default().with_id("My Button"); -/// let markup = html! { button (props.unpack(&cx)) { "OK" } }; +/// let markup = html! { button (props.unpack(&mut cx)) { "OK" } }; /// assert_eq!(markup.into_string(), r#""#); /// ``` /// @@ -73,13 +73,13 @@ use std::panic::Location; /// /// ```rust /// # use pagetop::prelude::*; -/// # let cx = Context::default(); +/// # 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(&cx)) { "OK" } }; +/// let markup = html! { button (props.unpack(&mut cx)) { "OK" } }; /// assert_eq!(markup.into_string(), r#""#); /// ``` /// @@ -90,14 +90,14 @@ use std::panic::Location; /// /// ```rust /// # use pagetop::prelude::*; -/// # let cx = Context::default(); +/// # 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(&cx)) { "OK" } }; +/// let markup = html! { button (props.unpack(&mut cx)) { "OK" } }; /// assert_eq!(markup.into_string(), r#""#); /// ``` /// @@ -111,10 +111,10 @@ use std::panic::Location; /// /// ```rust /// # use pagetop::prelude::*; -/// # let cx = Context::default(); +/// # let mut cx = Context::default(); /// let props = Props::default().with_prop(PropsOp::set("title", "de Props")); /// -/// let markup = html! { span title="literal" (props.unpack(&cx)) { "OK" } }; +/// 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"#); @@ -553,21 +553,54 @@ impl Props { // **< Props RENDER >*************************************************************************** - /// Extrae `Props` en la posición de atributos de [`html!`](crate::html::html) usando el - /// `Context` activo: `button (self.props().unpack(cx)) { ... }`. + /// 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. + /// 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 cx = Context::default(); + /// # let mut cx = Context::default(); /// let props = Props::default().with_id("example"); - /// let markup = html! { button (props.unpack(&cx)) { "OK" } }; + /// let markup = html! { button (props.unpack(&mut cx)) { "OK" } }; /// assert_eq!(markup.into_string(), r#""#); /// ``` - pub fn unpack<'a>(&'a self, cx: &'a Context) -> impl RenderAttrs + 'a { - PropsUnpack { props: self, cx } + /// + /// [`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 >************************************************************************** @@ -675,7 +708,7 @@ impl Props { // `#[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]) { + 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!( @@ -690,12 +723,15 @@ impl Props { w.push('"'); } } - if let Some((first, rest)) = self.classes.split_first() { + // 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 = %self.classes.join(" "), + discarded = %all_classes.join(" "), id = %self.id.as_deref().unwrap_or(""), "Ignoring Props attribute already set as a literal on the same element" ); @@ -756,16 +792,18 @@ impl Props { // **< PropsUnpack >******************************************************************************** -// Devuelto por `Props::unpack()`. +// 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, - cx: &'a Context, + 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.cx, w, exclude); + self.props.write_attrs(&self.classes, w, exclude); } } 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(), - "" + "" ); }