(base): Añade componente Table

Incluye columnas ordenables (`table::SortLink`), paso de props/atributos
a filas y celdas para HTMX, y un mensaje de "sin datos" traducible con
valor por defecto.
This commit is contained in:
Manuel Cillero 2026-08-13 15:17:23 +02:00
parent 17a9f0c9e0
commit 5842ea6efc
10 changed files with 723 additions and 0 deletions

View file

@ -34,3 +34,7 @@ pub use pager::{Pager, PagerAlign, PagerVisibility};
mod poweredby; mod poweredby;
pub use poweredby::PoweredBy; pub use poweredby::PoweredBy;
pub mod table;
#[doc(inline)]
pub use table::Table;

View file

@ -0,0 +1,16 @@
//! Definiciones para representar tablas de datos ([`Table`]).
mod props;
pub use props::SortLink;
mod component;
pub use component::Table;
mod column;
pub use column::Column;
mod row;
pub use row::Row;
mod cell;
pub use cell::Cell;

View file

@ -0,0 +1,114 @@
use crate::prelude::*;
/// Representa una celda de datos (`<td>`) de una [`table::Row`].
///
/// El contenido es una lista [`Children`] para admitir cualquier número de componentes.
/// [`Cell::new()`] añade un componente como contenido inicial, normalmente un [`Lc`] con texto
/// literal ([`Lc::n()`]) o traducible (con [`Lc::l()`]/[`Lc::t()`]).
///
/// Un `&str`, un `String` o un [`Lc`] se convierten directamente en `Cell`; para estos tipos no
/// sería necesario llamar a [`Cell::new()`] de forma explícita.
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let email: table::Cell = "ana@example.com".into();
/// let label: table::Cell = Lc::l("table-status").into();
/// let name = table::Cell::new(Html::with(|_| html! { "Julia" }));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Cell {
/// Devuelve identificador, clases CSS y atributos HTML de la celda (`<td>`).
props: Props,
/// Devuelve la lista de componentes hijo que genera el contenido de la celda.
children: Children,
}
impl Cell {
/// Crea una celda a partir del componente o texto ([`Lc`]) indicado.
///
/// Para combinar varios componentes en la misma celda basta con encadenar llamadas a
/// [`with_child()`](Cell::with_child):
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let status = table::Cell::new(Lc::n("Admin ")).with_child(Badge::labeled(Lc::n("active")));
/// ```
pub fn new(child: impl Into<Child>) -> Self {
Self {
children: Children::with(child.into()),
..Default::default()
}
}
// **< Cell BUILDER >***************************************************************************
/// Establece el identificador único de la celda.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML de la celda.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Establece el número de columnas que ocupa la celda (atributo `colspan`).
///
/// Con `1` (el valor por defecto de HTML) elimina el atributo en vez de fijarlo.
#[builder_fn]
pub fn with_colspan(mut self, span: u8) -> Self {
self.props.alter_prop(if span == 1 {
PropsOp::remove("colspan")
} else {
PropsOp::set("colspan", span.to_string())
});
self
}
/// Establece el número de filas que ocupa la celda (atributo `rowspan`).
///
/// Con `1` (el valor por defecto de HTML) elimina el atributo en vez de fijarlo.
#[builder_fn]
pub fn with_rowspan(mut self, span: u8) -> Self {
self.props.alter_prop(if span == 1 {
PropsOp::remove("rowspan")
} else {
PropsOp::set("rowspan", span.to_string())
});
self
}
/// Añade un nuevo componente a la celda o modifica la lista de componentes (`children`) con una
/// operación [`ChildOp`].
#[builder_fn]
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}
impl From<&str> for Cell {
/// Convierte un `&str` en una celda de texto literal; equivale a `Cell::new(Lc::n(text))`.
fn from(text: &str) -> Self {
Cell::new(Lc::n(text.to_string()))
}
}
impl From<String> for Cell {
/// Convierte un `String` en una celda de texto literal; equivale a `Cell::new(Lc::n(text))`.
fn from(text: String) -> Self {
Cell::new(Lc::n(text))
}
}
impl From<Lc> for Cell {
/// Convierte un [`Lc`] en una celda traducible; equivale a `Cell::new(label)`.
fn from(label: Lc) -> Self {
Cell::new(label)
}
}

View file

@ -0,0 +1,128 @@
use crate::prelude::*;
/// Cabecera de columna (`<th>`) de una [`Table`].
///
/// El contenido (`label`) es un [`Lc`]. Si la columna debe permitir ordenar la tabla al pulsarla,
/// añade un [`table::SortLink`] con [`with_sort()`](Self::with_sort) para que `Table` envuelva la
/// etiqueta en un enlace y añada el `aria-sort` y las clases CSS correspondientes.
///
/// Un `&str`, un `String` o un [`Lc`] se convierten directamente en `Column`; para estos tipos no
/// sería necesario llamar a [`Column::new()`] de forma explícita.
///
/// ```rust
/// use pagetop::locale::Lc;
/// use pagetop::base::component::table::Column;
///
/// let column: Column = Lc::n("User").into();
/// assert_eq!(column.label().get(), Some("User".to_string()));
/// assert!(column.sort().is_none());
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Column {
/// Devuelve identificador, clases CSS y atributos HTML de la celda de cabecera (`<th>`).
props: Props,
/// Devuelve el contenido de la cabecera.
label: Lc,
/// Devuelve el enlace de ordenación de la columna, si es ordenable.
sort: Option<table::SortLink>,
}
impl Column {
/// Crea una cabecera con el texto localizado indicado.
pub fn new(label: Lc) -> Self {
Self {
label,
..Default::default()
}
}
// **< Column BUILDER >*************************************************************************
/// Establece el identificador único de la celda de cabecera.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML de la columna.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Establece el número de columnas que ocupa la cabecera (atributo `colspan`).
///
/// Con `1` (el valor por defecto de HTML) elimina el atributo en vez de fijarlo.
#[builder_fn]
pub fn with_colspan(mut self, span: u8) -> Self {
self.props.alter_prop(if span == 1 {
PropsOp::remove("colspan")
} else {
PropsOp::set("colspan", span.to_string())
});
self
}
/// Establece el número de filas que ocupa la cabecera (atributo `rowspan`).
///
/// Con `1` (el valor por defecto de HTML) elimina el atributo en vez de fijarlo.
#[builder_fn]
pub fn with_rowspan(mut self, span: u8) -> Self {
self.props.alter_prop(if span == 1 {
PropsOp::remove("rowspan")
} else {
PropsOp::set("rowspan", span.to_string())
});
self
}
/// Convierte la columna en ordenable con el enlace indicado, o la vuelve no ordenable con
/// `None`.
#[builder_fn]
pub fn with_sort(mut self, sort: impl Into<Option<table::SortLink>>) -> Self {
self.sort = sort.into();
self
}
// 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 {
let label = self.label().using(cx);
let Some(sort) = self.sort() else {
return html! { th (self.props()) 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_str()] (link_props) { (label) }
}
}
}
}
impl From<&str> for Column {
/// Convierte un `&str` en una cabecera de texto literal; equivale a `Column::new(Lc::n(text))`.
fn from(text: &str) -> Self {
Column::new(Lc::n(text.to_string()))
}
}
impl From<String> for Column {
/// Convierte un `String` en una cabecera de texto literal; equivale a
/// `Column::new(Lc::n(text))`.
fn from(text: String) -> Self {
Column::new(Lc::n(text))
}
}
impl From<Lc> for Column {
/// Convierte un [`Lc`] en una cabecera de texto traducible; equivale a `Column::new(label)`.
fn from(label: Lc) -> Self {
Column::new(label)
}
}

View file

@ -0,0 +1,168 @@
use crate::prelude::*;
/// Componente para representar **tablas de datos**.
///
/// `Table` resuelve la estructura HTML de cualquier listado tabular (`<table>`, `<thead>`,
/// `<tbody>` y la fila para una tabla "sin resultados") y deja el resto en manos de quien lo usa:
/// qué contiene cada celda, qué columnas son ordenables y hacia dónde apuntan sus enlaces, y la
/// paginación (que se compone aparte, normalmente junto a `Table` dentro de un mismo contenedor).
///
/// # Clases CSS
///
/// - `.table-responsive`: envuelve `<table>` para admitir scroll horizontal sólo de la tabla si no
/// cabe en el ancho disponible.
/// - `.table`: clase base del elemento `<table>`.
/// - `.table-sort`: presente en el enlace de una cabecera ordenable.
/// - `.table-sort-asc` / `.table-sort-desc`: añadidas junto a `.table-sort` cuando esa columna es
/// la que determina el orden vigente. El tema activo puede usarlas para dibujar el indicador
/// visual (flecha, icono...) mediante CSS; `Table` no incluye ningún glifo por sí misma.
/// - `.table-empty`: clase de la celda con el mensaje mostrado cuando no hay filas.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let current_sort = "username";
/// let current_dir = SortDir::Asc;
///
/// // Definición de la tabla y cabeceras.
/// let mut table = Table::new()
/// .with_column(
/// table::Column::new(Lc::n("User")).with_sort(
/// table::SortLink::new("/admin/users?sort=username")
/// .with_dir((current_sort == "username").then_some(current_dir)),
/// ),
/// )
/// .with_column(Lc::n("Email"))
/// // Con `None` en vez de un `Lc`, se desactiva el mensaje predeterminado.
/// .with_empty(Lc::n("No users to display."));
///
/// // Contenido de la tabla.
/// for (username, email) in [("julia", "julia@example.com"), ("Fran", "fran@example.com")] {
/// table.alter_row(
/// table::Row::new()
/// // Ambos campos son del mismo tipo. Uno lo asignamos en un componente `Html`.
/// .with_cell(table::Cell::new(Html::with(move |_| html! { (username) })))
/// // Y el otro como `&str`, que se convierte directamente en `Cell`.
/// .with_cell(email),
/// );
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Table {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve las columnas de la tabla, en orden de aparición.
columns: Vec<table::Column>,
/// Devuelve las filas de datos de la tabla, en orden de aparición.
rows: Vec<table::Row>,
/// Devuelve el mensaje mostrado cuando no hay filas, o `None` si está desactivado.
#[default(Some(Lc::l("table_empty")))]
empty: Option<Lc>,
}
#[async_trait]
impl Component for Table {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
self.alter_prop(PropsOp::prepend_classes("table"));
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let colspan = self.columns().len().max(1).to_string();
Ok(html! {
div.table-responsive {
table (self.props()) {
@if !self.columns().is_empty() {
thead {
tr {
@for column in self.columns() {
(column.render_header(cx))
}
}
}
}
@if !self.rows().is_empty() {
tbody {
@for row in self.rows() {
tr (row.props()) {
@for cell in row.cells() {
td (cell.props()) { (cell.children().render(cx).await) }
}
}
}
}
} @else if let Some(empty) = self
.empty()
.and_then(|e| e.lookup(cx))
.filter(|s| !s.is_empty())
{
tbody {
tr {
td.table-empty colspan=(colspan) { (empty) }
}
}
}
}
}
})
}
}
impl Table {
// **< Table BUILDER >**************************************************************************
/// Establece el identificador único de la tabla.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML de la tabla.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Añade una columna al final de la cabecera.
///
/// Acepta directamente un `&str`, un `String` o un [`Lc`] (que equivalen a
/// `table::Column::new(...)` con el texto indicado), o un [`table::Column`] ya construido (por
/// ejemplo para asignarle clases, atributos propios o un enlace de ordenación con
/// `with_sort()`).
#[builder_fn]
pub fn with_column(mut self, column: impl Into<table::Column>) -> Self {
self.columns.push(column.into());
self
}
/// Añade una fila de datos al final de la tabla.
#[builder_fn]
pub fn with_row(mut self, row: table::Row) -> Self {
self.rows.push(row);
self
}
/// Sustituye el mensaje mostrado cuando no hay filas, o lo desactiva con `None`: en ese caso,
/// una tabla sin filas no muestra ninguna fila de reemplazo; sólo se renderizan `<table>` y, si
/// hay columnas, `<thead>`.
///
/// Ese mismo resultado se obtiene también si la traducción no resuelve a ningún texto (por
/// ejemplo, con `Lc::n("")`).
#[builder_fn]
pub fn with_empty(mut self, empty: impl Into<Option<Lc>>) -> Self {
self.empty = empty.into();
self
}
}

View file

@ -0,0 +1,93 @@
use crate::prelude::*;
/// Enlace de ordenación para una cabecera de columna ([`Column::with_sort`]).
///
/// Encapsula la URL de destino (`href`) y, si la tabla está actualmente ordenada por esta columna,
/// la dirección vigente (`dir`). [`Table`] usa esa información para marcar la cabecera con
/// `aria-sort` y aplicar las clases `table-sort`/`table-sort-asc`/`table-sort-desc` al enlace, de
/// modo que el tema activo pueda mostrar el indicador visual con CSS.
///
/// `SortLink` no añade ningún atributo adicional por sí sola: el enlace ya es funcional por sí
/// mismo (navega a `href` con una petición normal). Se pueden añadir atributos adicionales para
/// opciones de interactividad usando [`with_prop()`].
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let link = table::SortLink::new("/admin/users?sort=email")
/// .with_id("sort-email")
/// .with_dir(SortDir::Desc)
/// .with_prop(PropsOp::set("data-sort", "email"));
///
/// assert_eq!(link.props().get_id(), Some("sort-email".to_string()));
/// assert_eq!(link.href().as_str(), Some("/admin/users?sort=email"));
/// assert_eq!(link.dir(), Some(&SortDir::Desc));
/// ```
///
/// [`Table`]: super::Table
/// [`Column::with_sort`]: super::Column::with_sort
/// [`with_prop()`]: Self::with_prop
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct SortLink {
/// Devuelve los atributos adicionales del enlace.
props: Props,
/// Devuelve la URL de destino del enlace ya normalizada.
href: AttrValue,
/// Devuelve la dirección de orden vigente si la tabla está ordenada por esta columna, o
/// `None` si la columna es ordenable pero no es la que determina el orden actual.
dir: Option<SortDir>,
}
impl SortLink {
/// Crea un enlace de ordenación hacia la URL indicada, sin dirección activa.
pub fn new(href: impl Into<RoutePath>) -> Self {
Self {
href: AttrValue::new(href.into().to_string()),
..Default::default()
}
}
// **< SortLink BUILDER >***********************************************************************
/// Establece el identificador único del enlace.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Establece la dirección de orden vigente, o `None` si esta columna no es la que ordena
/// actualmente la tabla.
#[builder_fn]
pub fn with_dir(mut self, dir: impl Into<Option<SortDir>>) -> Self {
self.dir = dir.into();
self
}
/// Modifica los atributos HTML del enlace. Es el punto de extensión para añadir atributos de
/// interactividad sin que `Table` necesite conocerlos.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
// Determina el `aria-sort` y los atributos del enlace ya combinados con las clases
// `table-sort`/`table-sort-asc`/`table-sort-desc` correspondientes al estado de orden vigente.
// Sólo lo usa `Column` para montar la cabecera completa; no forma parte de la API pública.
pub(super) fn header_attrs(&self) -> (&'static str, Props) {
let (aria_sort, sort_class) = match self.dir() {
Some(dir) => match dir {
SortDir::Asc => ("ascending", "table-sort table-sort-asc"),
SortDir::Desc => ("descending", "table-sort table-sort-desc"),
},
None => ("none", "table-sort"),
};
let props = self
.props()
.clone()
.with_prop(PropsOp::prepend_classes(sort_class));
(aria_sort, props)
}
}

View file

@ -0,0 +1,54 @@
use crate::prelude::*;
/// Fila de datos (`<tr>`) de una [`Table`], formada por una lista de celdas ([`table::Cell`]).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let row = table::Row::new()
/// .with_cell("Julia")
/// .with_cell("ana@example.com");
/// assert_eq!(row.cells().len(), 2);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Row {
/// Devuelve identificador, clases CSS y atributos HTML de la fila (`<tr>`).
props: Props,
/// Devuelve las celdas de la fila, en orden de aparición.
cells: Vec<table::Cell>,
}
impl Row {
/// Crea una fila vacía.
pub fn new() -> Self {
Self::default()
}
// **< Row BUILDER >****************************************************************************
/// Establece el identificador único de la fila.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML de la fila.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Añade una celda al final de la fila.
///
/// Acepta directamente un `&str`, un `String` o un [`Lc`] (equivalen a `table::Cell::new(...)`
/// con el contenido indicado), o un [`table::Cell`] ya construido (por ejemplo para asignarle
/// clases o atributos propios, o para contener otros componentes).
#[builder_fn]
pub fn with_cell(mut self, cell: impl Into<table::Cell>) -> Self {
self.cells.push(cell.into());
self
}
}

View file

@ -31,3 +31,6 @@ pager_next_aria_label = Next page
pager_goto_label = Jump to page pager_goto_label = Jump to page
pager_goto_button = Go pager_goto_button = Go
pager_summary = Showing { $first }-{ $last } of { $total } pager_summary = Showing { $first }-{ $last } of { $total }
# Table component.
table_empty = No data to display

View file

@ -31,3 +31,6 @@ pager_next_aria_label = Página siguiente
pager_goto_label = Saltar a la página pager_goto_label = Saltar a la página
pager_goto_button = Ir pager_goto_button = Ir
pager_summary = Mostrando { $first }-{ $last } de { $total } pager_summary = Mostrando { $first }-{ $last } de { $total }
# Table component.
table_empty = No hay datos que mostrar

140
tests/component_table.rs Normal file
View file

@ -0,0 +1,140 @@
use pagetop::prelude::*;
#[pagetop::test]
async fn table_without_columns_or_rows_renders_empty_shell() {
let mut table = Table::new().with_empty(None);
let html = table.render(&mut Context::default()).await.into_string();
assert_eq!(
html,
r#"<div class="table-responsive"><table class="table"></table></div>"#
);
}
#[pagetop::test]
async fn table_without_rows_uses_default_empty_message() {
let mut table = Table::new();
let html = table.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<td class="table-empty" colspan="1">No data to display</td>"#));
}
#[pagetop::test]
async fn table_with_columns_and_no_rows_shows_empty_message() {
let mut table = Table::new()
.with_column(Lc::n("User"))
.with_column(Lc::n("Email"))
.with_empty(Lc::n("No users to show."));
let html = table.render(&mut Context::default()).await.into_string();
assert!(html.contains("<thead>"));
assert!(html.contains("<th scope=\"col\">User</th>"));
assert!(html.contains("<th scope=\"col\">Email</th>"));
assert!(html.contains(r#"<td class="table-empty" colspan="2">No users to show.</td>"#));
}
#[pagetop::test]
async fn table_with_columns_and_no_rows_and_no_empty_message_omits_tbody() {
let mut table = Table::new().with_column(Lc::n("User")).with_empty(None);
let html = table.render(&mut Context::default()).await.into_string();
assert!(html.contains("<thead>"));
assert!(!html.contains("<tbody>"));
}
#[pagetop::test]
async fn table_renders_rows_and_cells_in_order() {
let mut table = Table::new()
.with_column(Lc::n("User"))
.with_column(Lc::n("Email"));
table.alter_row(
table::Row::new()
.with_cell(table::Cell::new(Html::with(|_| html! { "alice" })))
.with_cell(table::Cell::new(Html::with(
|_| html! { "alice@example.com" },
))),
);
table.alter_row(
table::Row::new()
.with_cell(table::Cell::new(Html::with(|_| html! { "bob" })))
.with_cell(table::Cell::new(Html::with(
|_| html! { "bob@example.com" },
))),
);
let html = table.render(&mut Context::default()).await.into_string();
assert!(!html.contains("table-empty"));
let alice = html
.find("alice@example.com")
.expect("Expected alice's row");
let bob = html.find("bob@example.com").expect("Expected bob's row");
assert!(alice < bob, "Rows should keep insertion order");
}
#[pagetop::test]
async fn sortable_column_without_active_direction_marks_aria_sort_none() {
let mut table = Table::new().with_column(
table::Column::new(Lc::n("User")).with_sort(table::SortLink::new("/users?sort=username")),
);
let html = table.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"aria-sort="none""#));
assert!(html.contains(r#"<a href="/users?sort=username" class="table-sort">User</a>"#));
assert!(!html.contains("table-sort-asc"));
assert!(!html.contains("table-sort-desc"));
}
#[pagetop::test]
async fn sortable_column_with_active_direction_marks_aria_sort_and_css_class() {
let mut table = Table::new().with_column(
table::Column::new(Lc::n("User"))
.with_sort(table::SortLink::new("/users?sort=username").with_dir(SortDir::Desc)),
);
let html = table.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"aria-sort="descending""#));
assert!(html.contains("table-sort table-sort-desc"));
}
#[pagetop::test]
async fn sort_link_props_are_ready_for_htmx_without_hardcoding_it() {
let mut table = Table::new().with_column(
table::Column::new(Lc::n("User")).with_sort(
table::SortLink::new("/users?sort=username")
.with_prop(PropsOp::set("hx-get", "/users?sort=username"))
.with_prop(PropsOp::set("hx-target", "#user-table")),
),
);
let html = table.render(&mut Context::default()).await.into_string();
assert!(html.contains(r##"hx-get="/users?sort=username""##));
assert!(html.contains(r##"hx-target="#user-table""##));
}
#[pagetop::test]
async fn row_and_cell_props_flow_through_to_attributes() {
let mut table = Table::new().with_column(Lc::n("User"));
table.alter_row(
table::Row::new()
.with_prop(PropsOp::set("hx-target", "#row-1"))
.with_cell(
table::Cell::new(Html::with(|_| html! { "alice" }))
.with_prop(PropsOp::add_classes("is-admin")),
),
);
let html = table.render(&mut Context::default()).await.into_string();
assert!(html.contains(r##"<tr hx-target="#row-1">"##));
assert!(html.contains(r##"<td class="is-admin">alice</td>"##));
}