From 5842ea6efcbe24758469729dea11ec53c50954a9 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 13 Aug 2026 15:17:23 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(base):=20A=C3=B1ade=20componente?= =?UTF-8?q?=20Table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/base/component.rs | 4 + src/base/component/table.rs | 16 +++ src/base/component/table/cell.rs | 114 +++++++++++++++++ src/base/component/table/column.rs | 128 ++++++++++++++++++++ src/base/component/table/component.rs | 168 ++++++++++++++++++++++++++ src/base/component/table/props.rs | 93 ++++++++++++++ src/base/component/table/row.rs | 54 +++++++++ src/locale/en-US/base.ftl | 3 + src/locale/es-ES/base.ftl | 3 + tests/component_table.rs | 140 +++++++++++++++++++++ 10 files changed, 723 insertions(+) create mode 100644 src/base/component/table.rs create mode 100644 src/base/component/table/cell.rs create mode 100644 src/base/component/table/column.rs create mode 100644 src/base/component/table/component.rs create mode 100644 src/base/component/table/props.rs create mode 100644 src/base/component/table/row.rs create mode 100644 tests/component_table.rs diff --git a/src/base/component.rs b/src/base/component.rs index 6bb4e8b9..687d2995 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -34,3 +34,7 @@ pub use pager::{Pager, PagerAlign, PagerVisibility}; mod poweredby; pub use poweredby::PoweredBy; + +pub mod table; +#[doc(inline)] +pub use table::Table; diff --git a/src/base/component/table.rs b/src/base/component/table.rs new file mode 100644 index 00000000..725c26be --- /dev/null +++ b/src/base/component/table.rs @@ -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; diff --git a/src/base/component/table/cell.rs b/src/base/component/table/cell.rs new file mode 100644 index 00000000..0f49a826 --- /dev/null +++ b/src/base/component/table/cell.rs @@ -0,0 +1,114 @@ +use crate::prelude::*; + +/// Representa una celda de datos (``) 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 (``). + 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) -> 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) -> 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) -> 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 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 for Cell { + /// Convierte un [`Lc`] en una celda traducible; equivale a `Cell::new(label)`. + fn from(label: Lc) -> Self { + Cell::new(label) + } +} diff --git a/src/base/component/table/column.rs b/src/base/component/table/column.rs new file mode 100644 index 00000000..cc55ca71 --- /dev/null +++ b/src/base/component/table/column.rs @@ -0,0 +1,128 @@ +use crate::prelude::*; + +/// Cabecera de columna (``) 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 (``). + props: Props, + /// Devuelve el contenido de la cabecera. + label: Lc, + /// Devuelve el enlace de ordenación de la columna, si es ordenable. + sort: Option, +} + +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) -> 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>) -> 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 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 for Column { + /// Convierte un [`Lc`] en una cabecera de texto traducible; equivale a `Column::new(label)`. + fn from(label: Lc) -> Self { + Column::new(label) + } +} diff --git a/src/base/component/table/component.rs b/src/base/component/table/component.rs new file mode 100644 index 00000000..76857ef4 --- /dev/null +++ b/src/base/component/table/component.rs @@ -0,0 +1,168 @@ +use crate::prelude::*; + +/// Componente para representar **tablas de datos**. +/// +/// `Table` resuelve la estructura HTML de cualquier listado tabular (``, ``, +/// `` 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 `
` para admitir scroll horizontal sólo de la tabla si no +/// cabe en el ancho disponible. +/// - `.table`: clase base del elemento `
`. +/// - `.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, + /// Devuelve las filas de datos de la tabla, en orden de aparición. + rows: Vec, + /// Devuelve el mensaje mostrado cuando no hay filas, o `None` si está desactivado. + #[default(Some(Lc::l("table_empty")))] + empty: Option, +} + +#[async_trait] +impl Component for Table { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + 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 { + 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) -> 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) -> 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 `
` y, si + /// hay columnas, ``. + /// + /// 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>) -> Self { + self.empty = empty.into(); + self + } +} diff --git a/src/base/component/table/props.rs b/src/base/component/table/props.rs new file mode 100644 index 00000000..c6446e92 --- /dev/null +++ b/src/base/component/table/props.rs @@ -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, +} + +impl SortLink { + /// Crea un enlace de ordenación hacia la URL indicada, sin dirección activa. + pub fn new(href: impl Into) -> 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) -> 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>) -> 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) + } +} diff --git a/src/base/component/table/row.rs b/src/base/component/table/row.rs new file mode 100644 index 00000000..b73648fb --- /dev/null +++ b/src/base/component/table/row.rs @@ -0,0 +1,54 @@ +use crate::prelude::*; + +/// Fila de datos (``) 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 (``). + props: Props, + /// Devuelve las celdas de la fila, en orden de aparición. + cells: Vec, +} + +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) -> 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) -> Self { + self.cells.push(cell.into()); + self + } +} diff --git a/src/locale/en-US/base.ftl b/src/locale/en-US/base.ftl index d11c8cef..03e1aa76 100644 --- a/src/locale/en-US/base.ftl +++ b/src/locale/en-US/base.ftl @@ -31,3 +31,6 @@ pager_next_aria_label = Next page pager_goto_label = Jump to page pager_goto_button = Go pager_summary = Showing { $first }-{ $last } of { $total } + +# Table component. +table_empty = No data to display diff --git a/src/locale/es-ES/base.ftl b/src/locale/es-ES/base.ftl index fa0d4890..4c26244f 100644 --- a/src/locale/es-ES/base.ftl +++ b/src/locale/es-ES/base.ftl @@ -31,3 +31,6 @@ pager_next_aria_label = Página siguiente pager_goto_label = Saltar a la página pager_goto_button = Ir pager_summary = Mostrando { $first }-{ $last } de { $total } + +# Table component. +table_empty = No hay datos que mostrar diff --git a/tests/component_table.rs b/tests/component_table.rs new file mode 100644 index 00000000..35083540 --- /dev/null +++ b/tests/component_table.rs @@ -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#"
"# + ); +} + +#[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#"No data to display"#)); +} + +#[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("")); + assert!(html.contains("User")); + assert!(html.contains("Email")); + assert!(html.contains(r#"No users to show."#)); +} + +#[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("")); + assert!(!html.contains("")); +} + +#[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#"User"#)); + 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##""##)); + assert!(html.contains(r##"alice"##)); +}