Compare commits

..

No commits in common. "ab1d4f0efb179b1f2ea9b472cd7ca3df613dd425" and "17e16652e49a38ebf37018e5a7d7656a8195fc24" have entirely different histories.

52 changed files with 136 additions and 215 deletions

View file

@ -27,7 +27,7 @@ pub(crate) async fn render(dialog: &Dialog, cx: &mut Context) -> Result<Markup,
Ok(html! { Ok(html! {
div div
(dialog.props().unpack(cx)) (dialog.props())
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
aria-labelledby=[id_label.as_deref()] aria-labelledby=[id_label.as_deref()]

View file

@ -121,7 +121,7 @@ pub(crate) async fn render(
if title.is_empty() { if title.is_empty() {
// Sin título: menú contextual estático, sin botón ni comportamiento de apertura/cierre. // Sin título: menú contextual estático, sin botón ni comportamiento de apertura/cierre.
return Ok(html! { return Ok(html! {
div (dropdown.props().unpack(cx)) { div (dropdown.props()) {
ul class="dropdown-menu" { (items) } ul class="dropdown-menu" { (items) }
} }
}); });
@ -173,7 +173,7 @@ pub(crate) async fn render(
}; };
Ok(html! { Ok(html! {
div (dropdown.props().unpack(cx)) { div (dropdown.props()) {
// Renderizado en modo split (dos botones) o simple (un botón). // Renderizado en modo split (dos botones) o simple (un botón).
@if *dropdown.button_split() { @if *dropdown.button_split() {
// Botón principal (acción/etiqueta). // Botón principal (acción/etiqueta).

View file

@ -94,7 +94,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, Componen
}; };
Ok(html! { Ok(html! {
div (field.props().unpack(cx)) { div (field.props()) {
@if !floating { (label) } @if !floating { (label) }
input input
type=(field.kind()) type=(field.kind())

View file

@ -80,7 +80,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, Componen
None => html! {}, None => html! {},
}; };
Ok(html! { Ok(html! {
div (field.props().unpack(cx)) { div (field.props()) {
@if !floating { (label) } @if !floating { (label) }
select select
id=[select_id.as_deref()] id=[select_id.as_deref()]

View file

@ -86,7 +86,7 @@ pub(crate) fn render(field: &Textarea, cx: &mut Context) -> Result<Markup, Compo
None => html! {}, None => html! {},
}; };
Ok(html! { Ok(html! {
div (field.props().unpack(cx)) { div (field.props()) {
@if !floating { (label) } @if !floating { (label) }
textarea textarea
id=[textarea_id.as_deref()] id=[textarea_id.as_deref()]

View file

@ -48,7 +48,7 @@ impl Component for Icon {
let has_label = aria_label.is_some(); let has_label = aria_label.is_some();
html! { html! {
i i
(self.props().unpack(cx)) (self.props())
role=[has_label.then_some("img")] role=[has_label.then_some("img")]
aria-label=[aria_label] aria-label=[aria_label]
aria-hidden=[(!has_label).then_some("true")] aria-hidden=[(!has_label).then_some("true")]
@ -65,7 +65,7 @@ impl Component for Icon {
viewBox=(viewbox) viewBox=(viewbox)
fill="currentColor" fill="currentColor"
focusable="false" focusable="false"
(self.props().unpack(cx)) (self.props())
role=[has_label.then_some("img")] role=[has_label.then_some("img")]
aria-label=[aria_label] aria-label=[aria_label]
aria-hidden=[(!has_label).then_some("true")] aria-hidden=[(!has_label).then_some("true")]

View file

@ -101,7 +101,7 @@ pub(crate) async fn item_render(item: &Item, cx: &mut Context) -> Result<Markup,
ItemKind::Void => html! {}, ItemKind::Void => html! {},
ItemKind::Label(label) => html! { ItemKind::Label(label) => html! {
li (item.props().unpack(cx)) { li (item.props()) {
span class="nav-link disabled" aria-disabled="true" { span class="nav-link disabled" aria-disabled="true" {
(label.using(cx)) (label.using(cx))
} }
@ -137,7 +137,7 @@ pub(crate) async fn item_render(item: &Item, cx: &mut Context) -> Result<Markup,
let aria_disabled = (*disabled).then_some("true"); let aria_disabled = (*disabled).then_some("true");
html! { html! {
li (item.props().unpack(cx)) { li (item.props()) {
a a
class=(classes) class=(classes)
href=[href] href=[href]
@ -153,7 +153,7 @@ pub(crate) async fn item_render(item: &Item, cx: &mut Context) -> Result<Markup,
} }
ItemKind::Html(html) => html! { ItemKind::Html(html) => html! {
li (item.props().unpack(cx)) { li (item.props()) {
(html.render(cx).await) (html.render(cx).await)
} }
}, },
@ -170,7 +170,7 @@ pub(crate) async fn item_render(item: &Item, cx: &mut Context) -> Result<Markup,
.unwrap_or_else(|| "Dropdown".to_string()) .unwrap_or_else(|| "Dropdown".to_string())
}); });
html! { html! {
li (item.props().unpack(cx)) { li (item.props()) {
a a
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"

View file

@ -261,7 +261,7 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
.unwrap_or_else(|_| translate_layout(navbar.layout())); .unwrap_or_else(|_| translate_layout(navbar.layout()));
Ok(html! { Ok(html! {
nav (navbar.props().unpack(cx)) { nav (navbar.props()) {
div class="container-fluid" { div class="container-fluid" {
@match layout { @match layout {
// Barra más sencilla: sólo contenido. // Barra más sencilla: sólo contenido.

View file

@ -187,7 +187,7 @@ impl Offcanvas {
html! { html! {
div div
(self.props().unpack(cx)) (self.props())
tabindex="-1" tabindex="-1"
data-bs-scroll=[body_scroll] data-bs-scroll=[body_scroll]
data-bs-backdrop=[backdrop] data-bs-backdrop=[backdrop]

View file

@ -77,8 +77,8 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_prop(PropsOp::set(hx::TARGET, "#result")); .with_prop(PropsOp::set(hx::TARGET, "#result"));
Page::new(request) Page::new(request)
.with_child(Html::with(move |cx| html! { .with_child(Html::with(move |_| html! {
button (props.unpack(cx)) { "Say hello" } button (props) { "Say hello" }
div #result {} div #result {}
})) }))
.render().await .render().await

View file

@ -23,15 +23,15 @@
//! puedes usar [`Props`](pagetop::html::Props) combinado con las constantes de este módulo: //! puedes usar [`Props`](pagetop::html::Props) combinado con las constantes de este módulo:
//! //!
//! ```rust,no_run //! ```rust,no_run
//! # use pagetop::prelude::*; //! use pagetop::prelude::*;
//! # use pagetop_htmx::prelude::*; //! use pagetop_htmx::prelude::*;
//! # let cx = Context::default(); //!
//! let props = Props::new(hx::GET, "/api/items") //! let props = Props::new(hx::GET, "/api/items")
//! .with_prop(PropsOp::set(hx::TARGET, "#list")) //! .with_prop(PropsOp::set(hx::TARGET, "#list"))
//! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML)); //! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML));
//! //!
//! let markup = html! { //! let markup = html! {
//! button (props.unpack(&cx)) { "Load" } //! button (props) { "Load" }
//! }; //! };
//! ``` //! ```
//! //!

View file

@ -78,8 +78,8 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_prop(PropsOp::set(hx::TARGET, "#result")); .with_prop(PropsOp::set(hx::TARGET, "#result"));
Page::new(request) Page::new(request)
.with_child(Html::with(move |cx| html! { .with_child(Html::with(move |_| html! {
button (props.unpack(cx)) { "Say hello" } button (props) { "Say hello" }
div #result {} div #result {}
})) }))
.render().await .render().await

View file

@ -111,7 +111,7 @@ impl Component for RoleTable {
let new_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/new"))); let new_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/new")));
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
div.user-admin-actions { div.user-admin-actions {
a href=(new_href) { a href=(new_href) {
(Lc::t("btn-create-role", &LOCALES_USER).using(cx)) (Lc::t("btn-create-role", &LOCALES_USER).using(cx))

View file

@ -87,7 +87,7 @@ impl Component for UserTable {
let new_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/new"))); let new_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/new")));
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
div.user-admin-actions { div.user-admin-actions {
a href=(new_href) { a href=(new_href) {
(Lc::t("btn-create-user", &LOCALES_USER).using(cx)) (Lc::t("btn-create-user", &LOCALES_USER).using(cx))

View file

@ -42,7 +42,7 @@ impl Component for Badge {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> { async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! { Ok(html! {
span (self.props().unpack(cx)) { span (self.props()) {
(self.label().using(cx)) (self.label().using(cx))
} }
}) })

View file

@ -40,7 +40,7 @@ impl Component for Block {
} }
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if let Some(title) = self.title().lookup(cx) { @if let Some(title) = self.title().lookup(cx) {
h2 class="block-title" { span { (title) } } h2 class="block-title" { span { (title) } }
} }

View file

@ -69,9 +69,9 @@ impl Component for Brand {
} }
Ok(html! { Ok(html! {
@if let Some(route) = self.route() { @if let Some(route) = self.route() {
a (self.props().unpack(cx)) href=(route.resolve(cx)) { (inner_brand) } a (self.props()) href=(route.resolve(cx)) { (inner_brand) }
} @else { } @else {
span (self.props().unpack(cx)) { (inner_brand) } span (self.props()) { (inner_brand) }
} }
}) })
} }

View file

@ -56,7 +56,7 @@ impl Component for Breadcrumb {
} }
Ok(html! { Ok(html! {
nav (self.props().unpack(cx)) aria-label=[Lc::l("breadcrumb_label").lookup(cx)] { nav (self.props()) aria-label=[Lc::l("breadcrumb_label").lookup(cx)] {
ol.breadcrumb { ol.breadcrumb {
@for crumb in self.crumbs() { @for crumb in self.crumbs() {
(crumb.render_crumb(cx)) (crumb.render_crumb(cx))

View file

@ -92,12 +92,12 @@ impl Crumb {
let label = self.label().using(cx); let label = self.label().using(cx);
match self.route() { match self.route() {
Some(route) => html! { Some(route) => html! {
li (self.props().unpack(cx)) { li (self.props()) {
a href=(route.resolve(cx).to_string()) { (label) } a href=(route.resolve(cx).to_string()) { (label) }
} }
}, },
None => html! { None => html! {
li (self.props().unpack(cx)) { (label) } li (self.props()) { (label) }
}, },
} }
} }

View file

@ -103,7 +103,7 @@ impl Component for Button {
return Ok(html! { return Ok(html! {
a a
(self.props().unpack(cx)) (self.props())
href=[href] href=[href]
title=[self.title().lookup(cx)] title=[self.title().lookup(cx)]
autofocus[*self.autofocus()] autofocus[*self.autofocus()]
@ -118,7 +118,7 @@ impl Component for Button {
Ok(html! { Ok(html! {
button button
type=(self.kind()) type=(self.kind())
(self.props().unpack(cx)) (self.props())
name=[self.name().as_deref()] name=[self.name().as_deref()]
value=[self.value().as_deref()] value=[self.value().as_deref()]
title=[self.title().lookup(cx)] title=[self.title().lookup(cx)]

View file

@ -77,12 +77,12 @@ impl Component for Container {
return Ok(html! {}); return Ok(html! {});
} }
Ok(match self.kind() { Ok(match self.kind() {
Kind::Default => html! { div (self.props().unpack(cx)) { (output) } }, Kind::Default => html! { div (self.props()) { (output) } },
Kind::Main => html! { main (self.props().unpack(cx)) { (output) } }, Kind::Main => html! { main (self.props()) { (output) } },
Kind::Header => html! { header (self.props().unpack(cx)) { (output) } }, Kind::Header => html! { header (self.props()) { (output) } },
Kind::Footer => html! { footer (self.props().unpack(cx)) { (output) } }, Kind::Footer => html! { footer (self.props()) { (output) } },
Kind::Section => html! { section (self.props().unpack(cx)) { (output) } }, Kind::Section => html! { section (self.props()) { (output) } },
Kind::Article => html! { article (self.props().unpack(cx)) { (output) } }, Kind::Article => html! { article (self.props()) { (output) } },
}) })
} }
} }

View file

@ -82,7 +82,7 @@ impl Component for Dialog {
let id_label = (!title.is_empty()).then(|| util::join!(self.id().unwrap(), "-label")); let id_label = (!title.is_empty()).then(|| util::join!(self.id().unwrap(), "-label"));
Ok(html! { Ok(html! {
dialog (self.props().unpack(cx)) aria-labelledby=[id_label.as_deref()] { dialog (self.props()) aria-labelledby=[id_label.as_deref()] {
div class="dialog-header" { div class="dialog-header" {
@if let Some(id_label) = &id_label { @if let Some(id_label) = &id_label {
h2 id=(id_label) class="dialog-title" { (title) } h2 id=(id_label) class="dialog-title" { (title) }

View file

@ -103,7 +103,7 @@ impl Component for Dropdown {
let toggle_label = Lc::l("dropdown_toggle").using(cx); let toggle_label = Lc::l("dropdown_toggle").using(cx);
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if *self.button_split() { @if *self.button_split() {
button type="button" class=(&button_classes) { (&title) } button type="button" class=(&button_classes) { (&title) }
button button

View file

@ -63,7 +63,7 @@ impl Component for Item {
ItemKind::Void => html! {}, ItemKind::Void => html! {},
ItemKind::Label(label) => html! { ItemKind::Label(label) => html! {
li (self.props().unpack(cx)) { li (self.props()) {
span class="dropdown-item-text" { span class="dropdown-item-text" {
(label.using(cx)) (label.using(cx))
} }
@ -97,7 +97,7 @@ impl Component for Item {
let tabindex = disabled.then_some("-1"); let tabindex = disabled.then_some("-1");
html! { html! {
li (self.props().unpack(cx)) { li (self.props()) {
a a
class=(classes) class=(classes)
href=[href] href=[href]
@ -123,7 +123,7 @@ impl Component for Item {
let disabled_attr = disabled.then_some("disabled"); let disabled_attr = disabled.then_some("disabled");
html! { html! {
li (self.props().unpack(cx)) { li (self.props()) {
button button
class=(classes) class=(classes)
type="button" type="button"
@ -137,7 +137,7 @@ impl Component for Item {
} }
ItemKind::Header(label) => html! { ItemKind::Header(label) => html! {
li (self.props().unpack(cx)) { li (self.props()) {
h6 class="dropdown-header" { h6 class="dropdown-header" {
(label.using(cx)) (label.using(cx))
} }
@ -145,7 +145,7 @@ impl Component for Item {
}, },
ItemKind::Divider => html! { ItemKind::Divider => html! {
li (self.props().unpack(cx)) { hr class="dropdown-divider" {} } li (self.props()) { hr class="dropdown-divider" {} }
}, },
}) })
} }

View file

@ -144,7 +144,7 @@ impl Component for Field {
let container_id = self.id().unwrap(); let container_id = self.id().unwrap();
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if let Some(label) = self.label().lookup(cx) { @if let Some(label) = self.label().lookup(cx) {
label class="form-label" { (label) } label class="form-label" { (label) }
} }

View file

@ -105,7 +105,7 @@ impl Component for Checkbox {
let is_switch = *self.checkbox_kind() == form::CheckboxKind::Switch; let is_switch = *self.checkbox_kind() == form::CheckboxKind::Switch;
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
input input
type="checkbox" type="checkbox"
role=[is_switch.then_some("switch")] role=[is_switch.then_some("switch")]

View file

@ -78,7 +78,7 @@ impl Component for Form {
}; };
Ok(html! { Ok(html! {
form form
(self.props().unpack(cx)) (self.props())
action=[self.action().try_resolve(cx)] action=[self.action().try_resolve(cx)]
method=[method] method=[method]
accept-charset=[self.charset().as_deref()] accept-charset=[self.charset().as_deref()]

View file

@ -54,7 +54,7 @@ impl Component for Fieldset {
} }
Ok(html! { Ok(html! {
fieldset (self.props().unpack(cx)) disabled[*self.disabled()] { fieldset (self.props()) disabled[*self.disabled()] {
@if let Some(legend) = self.legend().lookup(cx) { @if let Some(legend) = self.legend().lookup(cx) {
legend { (legend) } legend { (legend) }
} }

View file

@ -234,7 +234,7 @@ impl Component for Field {
}; };
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if let Some(label) = self.label().lookup(cx) { @if let Some(label) = self.label().lookup(cx) {
label for=[input_id.as_deref()] class="form-label" { label for=[input_id.as_deref()] class="form-label" {
(label) (label)

View file

@ -86,7 +86,7 @@ impl Component for Number {
let container_id = self.id(); let container_id = self.id();
let input_id = container_id.as_deref().map(|id| util::join!(id, "-input")); let input_id = container_id.as_deref().map(|id| util::join!(id, "-input"));
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if let Some(label) = self.label().lookup(cx) { @if let Some(label) = self.label().lookup(cx) {
label for=[input_id.as_deref()] class="form-label" { label for=[input_id.as_deref()] class="form-label" {
(label) (label)

View file

@ -144,7 +144,7 @@ impl Component for Field {
let container_id = self.id().unwrap(); let container_id = self.id().unwrap();
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if let Some(label) = self.label().lookup(cx) { @if let Some(label) = self.label().lookup(cx) {
label class="form-label" { label class="form-label" {
(label) (label)

View file

@ -83,7 +83,7 @@ impl Component for Range {
let container_id = self.id(); let container_id = self.id();
let range_id = container_id.as_deref().map(|id| util::join!(id, "-range")); let range_id = container_id.as_deref().map(|id| util::join!(id, "-range"));
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if let Some(label) = self.label().lookup(cx) { @if let Some(label) = self.label().lookup(cx) {
label for=[range_id.as_deref()] class="form-label" { (label) } label for=[range_id.as_deref()] class="form-label" { (label) }
} }

View file

@ -242,7 +242,7 @@ impl Component for Field {
let select_id = container_id.as_deref().map(|id| util::join!(id, "-select")); let select_id = container_id.as_deref().map(|id| util::join!(id, "-select"));
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if let Some(label) = self.label().lookup(cx) { @if let Some(label) = self.label().lookup(cx) {
label for=[select_id.as_deref()] class="form-label" { label for=[select_id.as_deref()] class="form-label" {
(label) (label)

View file

@ -95,7 +95,7 @@ impl Component for Textarea {
.map(|id| util::join!(id, "-textarea")); .map(|id| util::join!(id, "-textarea"));
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) { div (self.props()) {
@if let Some(label) = self.label().lookup(cx) { @if let Some(label) = self.label().lookup(cx) {
label for=[textarea_id.as_deref()] class="form-label" { label for=[textarea_id.as_deref()] class="form-label" {
(label) (label)

View file

@ -77,9 +77,11 @@ impl Component for Image {
} }
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> { async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let alt_text = self.alternative().lookup(cx).unwrap_or_default();
let source = match self.source() { let source = match self.source() {
image::Source::Logo(svg) => { image::Source::Logo(svg) => {
return Ok(svg.markup_with(cx, self.props(), self.alternative().clone())); let label = (!alt_text.is_empty()).then_some(alt_text.as_str());
return Ok(svg.markup_with(self.props(), label));
} }
image::Source::Responsive(source) => Some(source), image::Source::Responsive(source) => Some(source),
image::Source::Thumbnail(source) => Some(source), image::Source::Thumbnail(source) => Some(source),
@ -88,8 +90,8 @@ impl Component for Image {
Ok(html! { Ok(html! {
img img
src=[source] src=[source]
alt=(self.alternative().lookup(cx).unwrap_or_default()) alt=(alt_text)
(self.props().unpack(cx)) {} (self.props()) {}
}) })
} }
} }

View file

@ -54,7 +54,7 @@ impl Component for Messages {
return Ok(html! {}); return Ok(html! {});
} }
Ok(html! { Ok(html! {
div (self.props().unpack(cx)) role="alert" { div (self.props()) role="alert" {
@for message in cx.messages() { @for message in cx.messages() {
div class=(match message.level() { div class=(match message.level() {
MessageLevel::Info => "message message-info", MessageLevel::Info => "message message-info",

View file

@ -62,7 +62,7 @@ impl Component for Nav {
} }
Ok(html! { Ok(html! {
ul (self.props().unpack(cx)) { ul (self.props()) {
(items) (items)
} }
}) })

View file

@ -86,7 +86,7 @@ impl Component for Item {
ItemKind::Void => html! {}, ItemKind::Void => html! {},
ItemKind::Label(label) => html! { ItemKind::Label(label) => html! {
li (self.props().unpack(cx)) { li (self.props()) {
span class="nav-link disabled" aria-disabled="true" { span class="nav-link disabled" aria-disabled="true" {
(label.using(cx)) (label.using(cx))
} }
@ -119,7 +119,7 @@ impl Component for Item {
let aria_disabled = (*disabled).then_some("true"); let aria_disabled = (*disabled).then_some("true");
html! { html! {
li (self.props().unpack(cx)) { li (self.props()) {
a a
class=(classes) class=(classes)
href=[route_link] href=[route_link]
@ -135,7 +135,7 @@ impl Component for Item {
} }
ItemKind::Html(html) => html! { ItemKind::Html(html) => html! {
li (self.props().unpack(cx)) { li (self.props()) {
(html.render(cx).await) (html.render(cx).await)
} }
}, },
@ -153,7 +153,7 @@ impl Component for Item {
title title
}; };
html! { html! {
li (self.props().unpack(cx)) { li (self.props()) {
a a
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
href="#" href="#"

View file

@ -139,36 +139,36 @@ impl Component for Navbar {
content_props.alter_prop(PropsOp::prepend_classes("navbar-content")); content_props.alter_prop(PropsOp::prepend_classes("navbar-content"));
Ok(html! { Ok(html! {
nav (self.props().unpack(cx)) { nav (self.props()) {
@match self.layout() { @match self.layout() {
// Barra más sencilla: sólo contenido, siempre visible. // Barra más sencilla: sólo contenido, siempre visible.
navbar::Layout::Simple => { navbar::Layout::Simple => {
div (content_props.unpack(cx)) { (items) } div (content_props) { (items) }
}, },
// Barra sencilla que se puede contraer/expandir. // Barra sencilla que se puede contraer/expandir.
navbar::Layout::SimpleToggle => { navbar::Layout::SimpleToggle => {
(button(cx, &id_content)) (button(cx, &id_content))
div id=(&id_content) (content_props.unpack(cx)) { (items) } div id=(&id_content) (content_props) { (items) }
}, },
// Barra con marca, siempre visible, sin botón. // Barra con marca, siempre visible, sin botón.
navbar::Layout::SimpleBrandLeft(brand) => { navbar::Layout::SimpleBrandLeft(brand) => {
(brand.render(cx).await) (brand.render(cx).await)
div (content_props.unpack(cx)) { (items) } div (content_props) { (items) }
}, },
// Barra con marca y botón, en ese orden. // Barra con marca y botón, en ese orden.
navbar::Layout::BrandLeft(brand) => { navbar::Layout::BrandLeft(brand) => {
(brand.render(cx).await) (brand.render(cx).await)
(button(cx, &id_content)) (button(cx, &id_content))
div id=(&id_content) (content_props.unpack(cx)) { (items) } div id=(&id_content) (content_props) { (items) }
}, },
// Barra con botón y marca, en ese orden. // Barra con botón y marca, en ese orden.
navbar::Layout::BrandRight(brand) => { navbar::Layout::BrandRight(brand) => {
(button(cx, &id_content)) (button(cx, &id_content))
div id=(&id_content) (content_props.unpack(cx)) { (items) } div id=(&id_content) (content_props) { (items) }
(brand.render(cx).await) (brand.render(cx).await)
}, },
} }

View file

@ -235,7 +235,7 @@ impl Component for Pager {
}; };
Ok(html! { Ok(html! {
nav (self.props().unpack(cx)) aria-label=[self.aria_label().lookup(cx)] { nav (self.props()) aria-label=[self.aria_label().lookup(cx)] {
@if show_summary { @if show_summary {
@let items_per_page = self.items_per_page().max(1); @let items_per_page = self.items_per_page().max(1);
@let first = (page - 1) * items_per_page + 1; @let first = (page - 1) * items_per_page + 1;

View file

@ -89,13 +89,13 @@ impl Column {
let label = self.label().using(cx); let label = self.label().using(cx);
let Some(sort) = self.sort() else { let Some(sort) = self.sort() else {
return html! { th (self.props().unpack(cx)) scope="col" { (label) } }; return html! { th (self.props()) scope="col" { (label) } };
}; };
let (aria_sort, link_props) = sort.header_attrs(); let (aria_sort, link_props) = sort.header_attrs();
html! { html! {
th (self.props().unpack(cx)) scope="col" aria-sort=(aria_sort) { th (self.props()) scope="col" aria-sort=(aria_sort) {
a href=[sort.href().as_deref()] (link_props.unpack(cx)) { (label) } a href=[sort.href().as_deref()] (link_props) { (label) }
} }
} }
} }

View file

@ -81,7 +81,7 @@ impl Component for Table {
Ok(html! { Ok(html! {
div.table-responsive { div.table-responsive {
table (self.props().unpack(cx)) { table (self.props()) {
@if !self.columns().is_empty() { @if !self.columns().is_empty() {
thead { thead {
tr { tr {
@ -94,9 +94,9 @@ impl Component for Table {
@if !self.rows().is_empty() { @if !self.rows().is_empty() {
tbody { tbody {
@for row in self.rows() { @for row in self.rows() {
tr (row.props().unpack(cx)) { tr (row.props()) {
@for cell in row.cells() { @for cell in row.cells() {
td (cell.props().unpack(cx)) { (cell.children().render(cx).await) } td (cell.props()) { (cell.children().render(cx).await) }
} }
} }
} }

View file

@ -1,7 +1,5 @@
use crate::AutoDefault; use crate::AutoDefault;
use crate::core::component::Context;
use crate::html::{Markup, Props, html}; use crate::html::{Markup, Props, html};
use crate::locale::Lc;
/// Representación SVG del **logotipo de PageTop** para incrustar en HTML. /// Representación SVG del **logotipo de PageTop** para incrustar en HTML.
/// ///
@ -57,32 +55,28 @@ impl PageTopSvg {
} }
} }
/// Igual que [`markup()`], pero incluye [`Props`] (identificador, clases, estilo, atributos) /// Igual que [`markup()`], pero fusiona [`Props`] (identificador, clases, estilo, atributos)
/// directamente en el `<svg>`, con etiqueta de accesibilidad (`aria-label`) según `label`; `cx` /// directamente en el `<svg>` y deja la etiqueta libre para quien llama. Con `Some(texto)` se
/// es el `Context` activo, necesario para combinarlo con `props` (ver [`Props::unpack()`]) y /// muestra como imagen informativa (`role="img"` + `aria-label`), y con `None` como imagen
/// para resolver `label`. Si `label` resuelve a texto se muestra como imagen informativa /// puramente decorativa (`aria-hidden="true"`, sin `role` ni `aria-label`).
/// (`role="img"` + `aria-label`); con [`Lc::none()`] o una traducción sin resultado, como
/// imagen puramente decorativa (`aria-hidden="true"`, sin `role` ni `aria-label`).
/// ///
/// Pensado para poner el logotipo con clases, estilo y accesibilidad propios, sin envolverlo en /// Pensado para poner el logotipo con clases, estilo y accesibilidad propios, sin envolverlo en
/// un elemento aparte; como hace el componente [`Image`] con [`image::Source::Logo`]. /// un elemento aparte; como hace el componente [`Image`] con [`image::Source::Logo`].
/// ///
/// [`markup()`]: Self::markup /// [`markup()`]: Self::markup
/// [`Lc::none()`]: crate::locale::Lc::none
/// [`Image`]: crate::base::component::Image /// [`Image`]: crate::base::component::Image
/// [`image::Source::Logo`]: crate::base::component::image::Source::Logo /// [`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, props: &Props, label: Option<&str>) -> Markup {
let label = label.lookup(cx);
html! { html! {
svg svg
viewBox="0 0 1614 1614" viewBox="0 0 1614 1614"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
role=[label.is_some().then_some("img")] role=[label.is_some().then_some("img")]
aria-label=[label.as_deref()] aria-label=[label]
aria-hidden=[label.is_none().then_some("true")] aria-hidden=[label.is_none().then_some("true")]
preserveAspectRatio="xMidYMid slice" preserveAspectRatio="xMidYMid slice"
focusable="false" focusable="false"
(props.unpack(cx)) (props)
{ {
(self.path_fills()) (self.path_fills())
} }

View file

@ -1,5 +1,4 @@
use crate::core::TypeInfo; use crate::core::TypeInfo;
use crate::core::component::Context;
use crate::html::flex::FlexItem; use crate::html::flex::FlexItem;
use crate::html::maud::{Escaper, RenderAttrs}; use crate::html::maud::{Escaper, RenderAttrs};
use crate::{AutoDefault, CowStr, builder_impl, trace, util}; use crate::{AutoDefault, CowStr, builder_impl, trace, util};
@ -334,18 +333,14 @@ impl PropsOp {
/// ///
/// # Ejemplo /// # 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 /// ```rust
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # let cx = Context::default();
/// let props = Props::new("hx-get", "/api/items") /// let props = Props::new("hx-get", "/api/items")
/// .with_prop(PropsOp::set("hx-target", "#lista")) /// .with_prop(PropsOp::set("hx-target", "#lista"))
/// .with_prop(PropsOp::set("hx-swap", "outerHTML")); /// .with_prop(PropsOp::set("hx-swap", "outerHTML"));
/// ///
/// let markup = html! { /// let markup = html! {
/// button (props.unpack(&cx)) { "Cargar" } /// button (props) { "Cargar" }
/// }; /// };
/// ///
/// assert_eq!( /// assert_eq!(
@ -362,9 +357,8 @@ impl PropsOp {
/// ///
/// ```rust /// ```rust
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # let cx = Context::default();
/// let props = Props::default().with_id("My Button"); /// let props = Props::default().with_id("My Button");
/// let markup = html! { button (props.unpack(&cx)) { "OK" } }; /// let markup = html! { button (props) { "OK" } };
/// assert_eq!(markup.into_string(), r#"<button id="my_button">OK</button>"#); /// assert_eq!(markup.into_string(), r#"<button id="my_button">OK</button>"#);
/// ``` /// ```
/// ///
@ -388,13 +382,12 @@ impl PropsOp {
/// ///
/// ```rust /// ```rust
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # let cx = Context::default();
/// let props = Props::default() /// let props = Props::default()
/// .with_prop(PropsOp::add_classes("btn btn-primary")) /// .with_prop(PropsOp::add_classes("btn btn-primary"))
/// .with_prop(PropsOp::add_classes("active")) /// .with_prop(PropsOp::add_classes("active"))
/// .with_prop(PropsOp::replace_classes("btn-primary", "btn-secondary")); /// .with_prop(PropsOp::replace_classes("btn-primary", "btn-secondary"));
/// ///
/// let markup = html! { button (props.unpack(&cx)) { "OK" } }; /// let markup = html! { button (props) { "OK" } };
/// assert_eq!(markup.into_string(), r#"<button class="btn btn-secondary active">OK</button>"#); /// assert_eq!(markup.into_string(), r#"<button class="btn btn-secondary active">OK</button>"#);
/// ``` /// ```
/// ///
@ -405,20 +398,19 @@ impl PropsOp {
/// ///
/// ```rust /// ```rust
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # let cx = Context::default();
/// let props = Props::default() /// let props = Props::default()
/// .with_prop(PropsOp::add_style("color", "red")) /// .with_prop(PropsOp::add_style("color", "red"))
/// .with_prop(PropsOp::add_style("font-weight", "bold")) /// .with_prop(PropsOp::add_style("font-weight", "bold"))
/// .with_prop(PropsOp::add_style("color", "blue")) /// .with_prop(PropsOp::add_style("color", "blue"))
/// .with_prop(PropsOp::remove_style("font-weight")); /// .with_prop(PropsOp::remove_style("font-weight"));
/// ///
/// let markup = html! { button (props.unpack(&cx)) { "OK" } }; /// let markup = html! { button (props) { "OK" } };
/// assert_eq!(markup.into_string(), r#"<button style="color: blue">OK</button>"#); /// assert_eq!(markup.into_string(), r#"<button style="color: blue">OK</button>"#);
/// ``` /// ```
/// ///
/// # Atributos duplicados junto a `Props` /// # Atributos duplicados junto a `Props`
/// ///
/// Cuando el componente combina `(self.props().unpack(cx))` con un atributo del mismo nombre en el /// Cuando el componente combina `(self.props())` con un atributo literal del mismo nombre en el
/// mismo elemento (una clase, un `#id`, o `nombre=valor`), la macro [`html!`](crate::html::html) /// 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 /// 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 /// atributos del elemento y al renderizar se omiten los duplicados en tiempo de ejecución. No
@ -426,10 +418,9 @@ impl PropsOp {
/// ///
/// ```rust /// ```rust
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// # let cx = Context::default();
/// let props = Props::default().with_prop(PropsOp::set("title", "de Props")); /// 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) { "OK" } };
/// ///
/// // El atributo literal prevalece; `Props` omite su propio "title" en vez de duplicarlo. /// // El atributo literal prevalece; `Props` omite su propio "title" en vez de duplicarlo.
/// assert_eq!(markup.into_string(), r#"<span title="literal">OK</span>"#); /// assert_eq!(markup.into_string(), r#"<span title="literal">OK</span>"#);
@ -480,7 +471,7 @@ impl PropsOp {
/// ///
/// async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> { /// async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
/// Ok(html! { /// Ok(html! {
/// button (self.props().unpack(cx)) { /// button (self.props()) {
/// (self.label().using(cx)) /// (self.label().using(cx))
/// } /// }
/// }) /// })
@ -878,25 +869,6 @@ impl Props {
self.extra::<T>(key).ok().cloned().unwrap_or_else(f) self.extra::<T>(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#"<button id="example">OK</button>"#);
/// ```
pub fn unpack<'a>(&'a self, cx: &'a Context) -> impl RenderAttrs + 'a {
PropsUnpack { props: self, cx }
}
// **< Props PRIVATE >************************************************************************** // **< Props PRIVATE >**************************************************************************
fn apply_id(&mut self, id: &str) { fn apply_id(&mut self, id: &str) {
@ -994,14 +966,14 @@ impl Props {
} }
} }
impl Props { #[doc(hidden)]
// Escribe los atributos, omitiendo cualquiera que esté en `exclude` (recopilados por `html!` a impl RenderAttrs for Props {
// partir de los atributos literales del elemento). Registra un `trace::debug!` por cada // Omite cualquier atributo que esté en `exclude` (recopilados por `html!` a partir de los
// atributo duplicado, con la posición exacta del `html!` que lo produjo (propagado gracias a // atributos literales del elemento). Registra un `trace::debug!` por cada atributo duplicado,
// `#[track_caller]`, heredado desde `PropsUnpack::render_attrs_to()`) para facilitar la // con la posición exacta del `html!` que lo produjo (propagado gracias a `#[track_caller]`)
// localización del problema. // para facilitar la localización del problema.
#[track_caller] #[track_caller]
fn write_attrs(&self, _cx: &Context, w: &mut String, exclude: &[&str]) { fn render_attrs_to(&self, w: &mut String, exclude: &[&str]) {
if let Some(id) = self.id.as_deref() { if let Some(id) = self.id.as_deref() {
if exclude.contains(&"id") { if exclude.contains(&"id") {
trace::debug!( trace::debug!(
@ -1079,19 +1051,3 @@ impl Props {
} }
} }
} }
// **< 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);
}
}

View file

@ -245,7 +245,7 @@ impl Page {
head { head {
(head) (head)
} }
body (self.body_props().unpack(&self.context)) { body (self.body_props()) {
(body) (body)
} }
} }

View file

@ -4,9 +4,8 @@ use pagetop::prelude::*;
#[pagetop::test] #[pagetop::test]
async fn props_default_renders_nothing() { async fn props_default_renders_nothing() {
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (Props::default().unpack(&cx)) {} }.into_string(), html! { span (Props::default()) {} }.into_string(),
"<span></span>" "<span></span>"
); );
} }
@ -45,9 +44,8 @@ async fn props_set_replaces_existing_value() {
async fn props_set_does_not_create_duplicate_key() { async fn props_set_does_not_create_duplicate_key() {
// Reassigning the same key must replace the value, not add a duplicate entry. // 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 p = Props::new("key", "v1").with_prop(PropsOp::set("key", "v2"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(), html! { span (p) {} }.into_string(),
r#"<span key="v2"></span>"# r#"<span key="v2"></span>"#
); );
} }
@ -57,9 +55,8 @@ async fn props_set_preserves_insertion_order() {
let p = Props::new("a", "1") let p = Props::new("a", "1")
.with_prop(PropsOp::set("b", "2")) .with_prop(PropsOp::set("b", "2"))
.with_prop(PropsOp::set("c", "3")); .with_prop(PropsOp::set("c", "3"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(), html! { span (p) {} }.into_string(),
r#"<span a="1" b="2" c="3"></span>"# r#"<span a="1" b="2" c="3"></span>"#
); );
} }
@ -85,11 +82,7 @@ async fn props_remove_nonexistent_key_is_noop() {
#[pagetop::test] #[pagetop::test]
async fn props_renders_nothing_after_removing_last_attr() { async fn props_renders_nothing_after_removing_last_attr() {
let p = Props::new("only", "one").with_prop(PropsOp::remove("only")); let p = Props::new("only", "one").with_prop(PropsOp::remove("only"));
let cx = Context::default(); assert_eq!(html! { span (p) {} }.into_string(), "<span></span>");
assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(),
"<span></span>"
);
} }
// **< HTML Escaped >******************************************************************************* // **< HTML Escaped >*******************************************************************************
@ -97,9 +90,8 @@ async fn props_renders_nothing_after_removing_last_attr() {
#[pagetop::test] #[pagetop::test]
async fn props_escapes_ampersand_and_angle_brackets_in_value() { async fn props_escapes_ampersand_and_angle_brackets_in_value() {
let p = Props::new("data-info", "a&b<c>d"); let p = Props::new("data-info", "a&b<c>d");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(), html! { span (p) {} }.into_string(),
r#"<span data-info="a&amp;b&lt;c&gt;d"></span>"# r#"<span data-info="a&amp;b&lt;c&gt;d"></span>"#
); );
} }
@ -107,9 +99,8 @@ async fn props_escapes_ampersand_and_angle_brackets_in_value() {
#[pagetop::test] #[pagetop::test]
async fn props_escapes_double_quotes_in_value() { async fn props_escapes_double_quotes_in_value() {
let p = Props::new("data-label", r#"say "hello""#); let p = Props::new("data-label", r#"say "hello""#);
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(), html! { span (p) {} }.into_string(),
r#"<span data-label="say &quot;hello&quot;"></span>"# r#"<span data-label="say &quot;hello&quot;"></span>"#
); );
} }
@ -120,9 +111,8 @@ async fn props_escapes_double_quotes_in_value() {
async fn props_empty_in_html_macro_produces_no_attributes() { async fn props_empty_in_html_macro_produces_no_attributes() {
// An empty Props must not emit even an extra blank space. // An empty Props must not emit even an extra blank space.
let p = Props::default(); let p = Props::default();
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) { "x" } }.into_string(), html! { button (p) { "x" } }.into_string(),
"<button>x</button>" "<button>x</button>"
); );
} }
@ -130,9 +120,8 @@ async fn props_empty_in_html_macro_produces_no_attributes() {
#[pagetop::test] #[pagetop::test]
async fn props_single_attr_in_html_macro() { async fn props_single_attr_in_html_macro() {
let p = Props::new("hx-get", "/api"); let p = Props::new("hx-get", "/api");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) { "Load" } }.into_string(), html! { button (p) { "Load" } }.into_string(),
r#"<button hx-get="/api">Load</button>"# r#"<button hx-get="/api">Load</button>"#
); );
} }
@ -142,20 +131,18 @@ async fn props_multiple_attrs_preserve_order_in_html_macro() {
let p = Props::new("hx-get", "/api") let p = Props::new("hx-get", "/api")
.with_prop(PropsOp::set("hx-target", "#result")) .with_prop(PropsOp::set("hx-target", "#result"))
.with_prop(PropsOp::set("hx-swap", "outerHTML")); .with_prop(PropsOp::set("hx-swap", "outerHTML"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) {} }.into_string(), html! { button (p) {} }.into_string(),
r##"<button hx-get="/api" hx-target="#result" hx-swap="outerHTML"></button>"## r##"<button hx-get="/api" hx-target="#result" hx-swap="outerHTML"></button>"##
); );
} }
#[pagetop::test] #[pagetop::test]
async fn props_alongside_class_and_id_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. // The splice is always emitted after class and id, regardless of the order they are written in.
let p = Props::new("hx-get", "/api"); let p = Props::new("hx-get", "/api");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button #mybtn .btn (p.unpack(&cx)) { "Go" } }.into_string(), html! { button #mybtn .btn (p) { "Go" } }.into_string(),
r#"<button class="btn" id="mybtn" hx-get="/api">Go</button>"# r#"<button class="btn" id="mybtn" hx-get="/api">Go</button>"#
); );
} }
@ -163,44 +150,40 @@ async fn props_alongside_class_and_id_in_html_macro() {
#[pagetop::test] #[pagetop::test]
async fn props_alongside_named_attr_renders_after_it() { async fn props_alongside_named_attr_renders_after_it() {
let p = Props::new("hx-get", "/api"); let p = Props::new("hx-get", "/api");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button type="button" (p.unpack(&cx)) {} }.into_string(), html! { button type="button" (p) {} }.into_string(),
r#"<button type="button" hx-get="/api"></button>"# r#"<button type="button" hx-get="/api"></button>"#
); );
} }
#[pagetop::test] #[pagetop::test]
async fn props_combined_via_chaining_instead_of_multiple_splices() { async fn props_combined_via_chaining_instead_of_multiple_splices() {
// An element accepts only a single attribute unpack (a second `(props)` on the same element // An element accepts only a single attribute splice (a second `(props)` on the same element
// is a compile error); values from separate sources are combined by chaining `with_prop()` // is a compile error); values from separate sources are combined by chaining `with_prop()`
// on one `Props`, not by splicing two of them. // on one `Props`, not by splicing two of them.
let p = Props::new("hx-get", "/api").with_prop(PropsOp::set("hx-swap", "outerHTML")); let p = Props::new("hx-get", "/api").with_prop(PropsOp::set("hx-swap", "outerHTML"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) {} }.into_string(), html! { button (p) {} }.into_string(),
r#"<button hx-get="/api" hx-swap="outerHTML"></button>"# r#"<button hx-get="/api" hx-swap="outerHTML"></button>"#
); );
} }
#[pagetop::test] #[pagetop::test]
async fn props_inline_construction_in_html_macro() { async fn props_inline_construction_in_html_macro() {
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (Props::new("hx-get", "/api").unpack(&cx)) { "Go" } }.into_string(), html! { button (Props::new("hx-get", "/api")) { "Go" } }.into_string(),
r#"<button hx-get="/api">Go</button>"# r#"<button hx-get="/api">Go</button>"#
); );
} }
#[pagetop::test] #[pagetop::test]
async fn props_conditional_expression_in_html_macro() { async fn props_conditional_expression_in_html_macro() {
let cx = Context::default();
for (active, expected) in [ for (active, expected) in [
(true, r#"<button hx-get="/api">x</button>"#), (true, r#"<button hx-get="/api">x</button>"#),
(false, "<button>x</button>"), (false, "<button>x</button>"),
] { ] {
let markup = html! { 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() }) { "x" }
}; };
assert_eq!(markup.into_string(), expected); assert_eq!(markup.into_string(), expected);
} }
@ -208,7 +191,7 @@ async fn props_conditional_expression_in_html_macro() {
#[pagetop::test] #[pagetop::test]
async fn props_splice_empty_string_emits_nothing() { async fn props_splice_empty_string_emits_nothing() {
// An empty unpack emits no attribute nor extra space. // An empty splice emits no attribute nor extra space.
assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>"); assert_eq!(html! { span ("") { "x" } }.into_string(), "<span>x</span>");
} }
@ -219,9 +202,8 @@ 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 // A literal `#id` on the element takes precedence; `Props`'s own id is silently omitted instead
// of producing a duplicate `id` attribute. // of producing a duplicate `id` attribute.
let p = Props::default().with_id("from-props"); let p = Props::default().with_id("from-props");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { div #fixed (p.unpack(&cx)) {} }.into_string(), html! { div #fixed (p) {} }.into_string(),
r#"<div id="fixed"></div>"# r#"<div id="fixed"></div>"#
); );
} }
@ -229,9 +211,8 @@ async fn props_id_collision_with_literal_omits_props_id() {
#[pagetop::test] #[pagetop::test]
async fn props_class_collision_with_literal_omits_props_classes() { async fn props_class_collision_with_literal_omits_props_classes() {
let p = Props::classes("from-props-a from-props-b"); let p = Props::classes("from-props-a from-props-b");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { div.fixed (p.unpack(&cx)) {} }.into_string(), html! { div.fixed (p) {} }.into_string(),
r#"<div class="fixed"></div>"# r#"<div class="fixed"></div>"#
); );
} }
@ -241,9 +222,8 @@ async fn props_style_collision_with_literal_omits_props_styles() {
let p = Props::default() let p = Props::default()
.with_prop(PropsOp::add_style("color", "red")) .with_prop(PropsOp::add_style("color", "red"))
.with_prop(PropsOp::add_style("font-weight", "bold")); .with_prop(PropsOp::add_style("font-weight", "bold"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { div style="color: blue" (p.unpack(&cx)) {} }.into_string(), html! { div style="color: blue" (p) {} }.into_string(),
r#"<div style="color: blue"></div>"# r#"<div style="color: blue"></div>"#
); );
} }
@ -251,9 +231,8 @@ async fn props_style_collision_with_literal_omits_props_styles() {
#[pagetop::test] #[pagetop::test]
async fn props_named_attr_collision_with_literal_omits_props_value() { async fn props_named_attr_collision_with_literal_omits_props_value() {
let p = Props::default().with_prop(PropsOp::set("title", "from-props")); let p = Props::default().with_prop(PropsOp::set("title", "from-props"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span title="literal" (p.unpack(&cx)) {} }.into_string(), html! { span title="literal" (p) {} }.into_string(),
r#"<span title="literal"></span>"# r#"<span title="literal"></span>"#
); );
} }
@ -321,9 +300,8 @@ async fn get_prop_id_matches_get_id() {
async fn props_hx_target_value_with_hash_renders_correctly() { async fn props_hx_target_value_with_hash_renders_correctly() {
// Regression: r#"..."# used to close prematurely when it found `"#list"`. // Regression: r#"..."# used to close prematurely when it found `"#list"`.
let p = Props::new("hx-target", "#list"); let p = Props::new("hx-target", "#list");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) {} }.into_string(), html! { button (p) {} }.into_string(),
r##"<button hx-target="#list"></button>"## r##"<button hx-target="#list"></button>"##
); );
} }
@ -331,9 +309,8 @@ async fn props_hx_target_value_with_hash_renders_correctly() {
#[pagetop::test] #[pagetop::test]
async fn props_with_empty_value_renders_attr_with_empty_value() { async fn props_with_empty_value_renders_attr_with_empty_value() {
let p = Props::new("data-expanded", ""); let p = Props::new("data-expanded", "");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(), html! { span (p) {} }.into_string(),
r#"<span data-expanded=""></span>"# r#"<span data-expanded=""></span>"#
); );
} }
@ -348,9 +325,8 @@ 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("a"), Some("updated".to_string()));
assert_eq!(p.get_prop("b"), None); assert_eq!(p.get_prop("b"), None);
assert_eq!(p.get_prop("c"), Some("3".to_string())); assert_eq!(p.get_prop("c"), Some("3".to_string()));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(), html! { span (p) {} }.into_string(),
r#"<span a="updated" c="3"></span>"# r#"<span a="updated" c="3"></span>"#
); );
} }
@ -359,9 +335,8 @@ async fn props_chained_set_and_remove_yields_expected_state() {
async fn props_with_empty_attr_name_renders_without_validation() { async fn props_with_empty_attr_name_renders_without_validation() {
// Documented behavior: names are not validated; the resulting HTML is not standard. // Documented behavior: names are not validated; the resulting HTML is not standard.
let p = Props::new("", "val"); let p = Props::new("", "val");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(), html! { span (p) {} }.into_string(),
r#"<span ="val"></span>"# r#"<span ="val"></span>"#
); );
} }

View file

@ -299,9 +299,8 @@ async fn get_prop_class_matches_get_classes() {
#[pagetop::test] #[pagetop::test]
async fn props_classes_renders_class_attribute() { async fn props_classes_renders_class_attribute() {
let p = Props::classes("btn btn-primary"); let p = Props::classes("btn btn-primary");
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) { "OK" } }.into_string(), html! { button (p) { "OK" } }.into_string(),
r#"<button class="btn btn-primary">OK</button>"# r#"<button class="btn btn-primary">OK</button>"#
); );
} }
@ -309,9 +308,8 @@ async fn props_classes_renders_class_attribute() {
#[pagetop::test] #[pagetop::test]
async fn props_classes_can_be_extended_with_add_classes() { async fn props_classes_can_be_extended_with_add_classes() {
let p = Props::classes("btn").with_prop(PropsOp::add_classes("active")); let p = Props::classes("btn").with_prop(PropsOp::add_classes("active"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) { "OK" } }.into_string(), html! { button (p) { "OK" } }.into_string(),
r#"<button class="btn active">OK</button>"# r#"<button class="btn active">OK</button>"#
); );
} }

View file

@ -113,9 +113,8 @@ async fn extras_not_emitted_in_html() {
let props = Props::default() let props = Props::default()
.with_prop(PropsOp::set_extra("ext.flag", true)) .with_prop(PropsOp::set_extra("ext.flag", true))
.with_prop(PropsOp::add_classes("btn")); .with_prop(PropsOp::add_classes("btn"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (props.unpack(&cx)) { "OK" } }.into_string(), html! { button (props) { "OK" } }.into_string(),
r#"<button class="btn">OK</button>"# r#"<button class="btn">OK</button>"#
); );
} }

View file

@ -275,9 +275,8 @@ async fn props_styles_renders_style_attribute() {
let p = Props::default() let p = Props::default()
.with_prop(PropsOp::add_style("color", "red")) .with_prop(PropsOp::add_style("color", "red"))
.with_prop(PropsOp::add_style("font-weight", "bold")); .with_prop(PropsOp::add_style("font-weight", "bold"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) { "OK" } }.into_string(), html! { button (p) { "OK" } }.into_string(),
r#"<button style="color: red; font-weight: bold">OK</button>"# r#"<button style="color: red; font-weight: bold">OK</button>"#
); );
} }
@ -289,9 +288,8 @@ async fn props_styles_render_after_class_and_before_other_attrs() {
.with_prop(PropsOp::add_classes("btn")) .with_prop(PropsOp::add_classes("btn"))
.with_prop(PropsOp::add_style("color", "red")) .with_prop(PropsOp::add_style("color", "red"))
.with_prop(PropsOp::set("data-x", "1")); .with_prop(PropsOp::set("data-x", "1"));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { button (p.unpack(&cx)) { "OK" } }.into_string(), html! { button (p) { "OK" } }.into_string(),
r#"<button id="main" class="btn" style="color: red" data-x="1">OK</button>"# r#"<button id="main" class="btn" style="color: red" data-x="1">OK</button>"#
); );
} }
@ -299,9 +297,8 @@ async fn props_styles_render_after_class_and_before_other_attrs() {
#[pagetop::test] #[pagetop::test]
async fn props_styles_escapes_double_quotes_in_value() { async fn props_styles_escapes_double_quotes_in_value() {
let p = Props::default().with_prop(PropsOp::add_style("content", r#""hi""#)); let p = Props::default().with_prop(PropsOp::add_style("content", r#""hi""#));
let cx = Context::default();
assert_eq!( assert_eq!(
html! { span (p.unpack(&cx)) {} }.into_string(), html! { span (p) {} }.into_string(),
r#"<span style="content: &quot;hi&quot;"></span>"# r#"<span style="content: &quot;hi&quot;"></span>"#
); );
} }