✨ (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:
parent
17a9f0c9e0
commit
5842ea6efc
10 changed files with 723 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
16
src/base/component/table.rs
Normal file
16
src/base/component/table.rs
Normal 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;
|
||||
114
src/base/component/table/cell.rs
Normal file
114
src/base/component/table/cell.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
128
src/base/component/table/column.rs
Normal file
128
src/base/component/table/column.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
168
src/base/component/table/component.rs
Normal file
168
src/base/component/table/component.rs
Normal 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
|
||||
}
|
||||
}
|
||||
93
src/base/component/table/props.rs
Normal file
93
src/base/component/table/props.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
54
src/base/component/table/row.rs
Normal file
54
src/base/component/table/row.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
140
tests/component_table.rs
Normal file
140
tests/component_table.rs
Normal 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>"##));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue