♻️ (pagetop): Incorpora Context al render de Props

This commit is contained in:
Manuel Cillero 2026-09-05 00:19:39 +02:00
parent a2b3ae2eb8
commit ab1d4f0efb
49 changed files with 212 additions and 133 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -77,11 +77,9 @@ impl Component for Image {
}
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() {
image::Source::Logo(svg) => {
let label = (!alt_text.is_empty()).then_some(alt_text.as_str());
return Ok(svg.markup_with(self.props(), label));
return Ok(svg.markup_with(cx, self.props(), self.alternative().clone()));
}
image::Source::Responsive(source) => Some(source),
image::Source::Thumbnail(source) => Some(source),
@ -90,8 +88,8 @@ impl Component for Image {
Ok(html! {
img
src=[source]
alt=(alt_text)
(self.props()) {}
alt=(self.alternative().lookup(cx).unwrap_or_default())
(self.props().unpack(cx)) {}
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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