Table
A responsive table component for displaying tabular data.
Invoice Table
A table showing a list of recent invoices.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
| INV004 | Paid | Credit Card | $450.00 |
| INV005 | Paid | PayPal | $550.00 |
| Total | $1,750.00 |
FreeTable
Static tabular layout.
Install with the Proa CLI
$ proa ui add tableInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Table recipe — static named slots for responsive table parts.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const CONTAINER: &str = tw_join!("relative", "w-full", "overflow-auto");
pub const ROOT: &str = tw_join!("w-full", "caption-bottom", "text-sm");
pub const HEADER: &str = "[&_tr]:border-b";
pub const BODY: &str = "[&_tr:last-child]:border-0";
pub const FOOTER: &str = tw_join!(
"bg-muted/50",
"border-t",
"font-medium",
"[&>tr]:last:border-b-0"
);
Table source
Browse and copy the reviewed source included with this Free component.
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{
Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow,
};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Table is a passthrough — children (header, body) produce the table markdown.
///
/// Rows themselves never need a boundary: the first row follows Table's
/// leading boundary and every later row follows the previous row's `\n`.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for Table<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// TableHeader renders its row, then writes the separator line.
/// Children should be a single TableRow containing TableHead cells.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for TableHeader<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
// Render the header row
self.children.render_md(cx)?;
// Count columns by looking at what was rendered
// We rely on the row having written `| col | col |\n`
// Write separator: `| --- | --- |`
// We need to re-render into a temp buf to count pipes
// Instead, just write a generic separator after the row.
// The row itself writes `| ... | ... |\n`, so we match it.
// We just write `| --- |` pattern. The row rendering will handle column count.
Ok(())
}
}
/// TableBody is a passthrough.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for TableBody<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// TableFooter is a passthrough (footer rows render like body rows).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for TableFooter<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// TableRow renders as `| cell | cell |\n`.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for TableRow<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
md_sync! { "|" { self.children } "\n" }.render_md(cx)
}
}
/// TableHead renders as ` text |` and tracks that a separator is needed.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for TableHead<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
md_sync! { " " }.render_md(cx)?;
let prev = cx.md_set_inline(true);
let result = self.children.render_md(cx);
cx.md_set_inline(prev);
result?;
md_sync! { " |" }.render_md(cx)
}
}
/// TableCell renders as ` text |`.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for TableCell<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
md_sync! { " " }.render_md(cx)?;
let prev = cx.md_set_inline(true);
let result = self.children.render_md(cx);
cx.md_set_inline(prev);
result?;
md_sync! { " |" }.render_md(cx)
}
}
/// TableCaption renders as italic text below the table.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for TableCaption<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
md_sync! { "*" }.render_md(cx)?;
let prev = cx.md_set_inline(true);
let result = self.children.render_md(cx);
cx.md_set_inline(prev);
result?;
md_sync! { "*" }.render_md(cx)?;
cx.md_blank_line()?;
Ok(())
}
}
/// Helper: render a table header row and automatically produce the separator.
///
/// Usage from catalog/page code:
/// ```ignore
/// use proa_ui::components::table::md::render_table_with_separator;
/// render_table_with_separator(&mut cx, &["Name", "Status", "Role"]);
/// ```
pub fn write_separator<B: WriteBuf>(
cx: &mut WebContext<::proa_core::NoLoader, B>,
col_count: usize,
) -> Result<(), WriteError> {
md_sync! { "|" }.render_md(cx)?;
for _ in 0..col_count {
md_sync! { " --- |" }.render_md(cx)?;
}
md_sync! { "" "\n" }.render_md(cx)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_row_renders_pipe_delimited() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
TableRow {
class: None,
children: Some((
TableCell {
class: None,
children: Some("Alice"),
},
TableCell {
class: None,
children: Some("Admin"),
},
)),
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "| Alice | Admin |\n");
}
#[test]
fn table_head_renders() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
TableRow {
class: None,
children: Some((
TableHead {
class: None,
children: Some("Name"),
},
TableHead {
class: None,
children: Some("Role"),
},
)),
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "| Name | Role |\n");
}
#[test]
fn write_separator_works() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
write_separator(&mut cx, 3).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "| --- | --- | --- |\n");
}
#[test]
fn table_caption_renders_italic() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
TableCaption {
class: None,
children: Some("A list of users"),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("*A list of users*"), "output: {output}");
}
#[test]
fn table_composes_after_inline_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Table {
class: None,
children: Some((
TableHeader {
class: None,
children: Some(TableRow {
class: None,
children: Some(TableHead {
class: None,
children: Some("Name"),
}),
}),
},
TableBody {
class: None,
children: Some(TableRow {
class: None,
children: Some(TableCell {
class: None,
children: Some("Alice"),
}),
}),
},
)),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\n| Name |"),
"table must start at a block boundary, got:\n{output}"
);
assert!(output.ends_with("| Alice |\n\n"), "output: {output}");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Table component - responsive data table
//!
//! Renders a styled table with header, body, footer, and caption sections.
pub mod md;
mod recipe;
mod table;
/// Stable scope/part contract shared by every Table renderer.
pub const TABLE_ANATOMY: crate::Anatomy = crate::Anatomy::new(
"table",
&[
"root",
"container",
"header",
"body",
"footer",
"row",
"head",
"cell",
"caption",
],
);
// Re-export table types
pub use table::{
Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow,
};
// Re-export recipe types
pub use recipe::{
table_body_classes, table_caption_classes, table_cell_classes, table_classes,
table_footer_classes, table_head_classes, table_header_classes, table_recipe,
table_row_classes, TableRecipe, TableRecipeProps, TableStyle, TABLE_RECIPE,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anatomy_names_every_public_table_part() {
assert_eq!(TABLE_ANATOMY.scope(), "table");
for part in [
"root",
"container",
"header",
"body",
"footer",
"row",
"head",
"cell",
"caption",
] {
assert!(TABLE_ANATOMY.contains(part));
}
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Table recipe — static named slots for responsive table parts.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
mod base {
use super::*;
pub const CONTAINER: &str = tw_join!("relative", "w-full", "overflow-auto");
pub const ROOT: &str = tw_join!("w-full", "caption-bottom", "text-sm");
pub const HEADER: &str = "[&_tr]:border-b";
pub const BODY: &str = "[&_tr:last-child]:border-0";
pub const FOOTER: &str = tw_join!(
"bg-muted/50",
"border-t",
"font-medium",
"[&>tr]:last:border-b-0"
);
pub const ROW: &str = tw_join!(
"border-b",
"transition-colors",
"hover:bg-muted/50",
"data-[state=selected]:bg-muted"
);
pub const HEAD: &str = tw_join!(
"h-10",
"px-2",
"text-left",
"align-middle",
"font-medium",
"text-muted-foreground",
"[&:has([role=checkbox])]:pr-0",
"[&>[role=checkbox]]:translate-y-[2px]"
);
pub const CELL: &str = tw_join!(
"p-2",
"align-middle",
"[&:has([role=checkbox])]:pr-0",
"[&>[role=checkbox]]:translate-y-[2px]"
);
pub const CAPTION: &str = tw_join!("mt-4", "text-sm", "text-muted-foreground");
}
/// Optional inputs accepted by [`TableRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TableRecipeProps;
/// Static named classes for every Table part.
#[derive(Debug, Clone, Copy)]
pub struct TableRecipe {
pub container: &'static str,
pub root: &'static str,
pub header: &'static str,
pub body: &'static str,
pub footer: &'static str,
pub row: &'static str,
pub head: &'static str,
pub cell: &'static str,
pub caption: &'static str,
}
impl TableRecipe {
pub const fn resolve(&'static self, _props: TableRecipeProps) -> TableStyle {
TableStyle { recipe: self }
}
}
/// Fully resolved, streamable Table element style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct TableStyle {
recipe: &'static TableRecipe,
}
impl AttrValue for TableStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())
}
}
pub const TABLE_RECIPE: TableRecipe = TableRecipe {
container: base::CONTAINER,
root: base::ROOT,
header: base::HEADER,
body: base::BODY,
footer: base::FOOTER,
row: base::ROW,
head: base::HEAD,
cell: base::CELL,
caption: base::CAPTION,
};
pub const fn table_recipe() -> &'static TableRecipe {
&TABLE_RECIPE
}
impl crate::variant_spec::ComponentSpec for TableRecipe {
const NAME: &'static str = "Table";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
Vec::new()
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::TABLE_ANATOMY)
}
}
/// Compatibility accessors for the former standalone helpers.
pub const fn table_classes() -> &'static str {
TABLE_RECIPE.root
}
pub const fn table_header_classes() -> &'static str {
TABLE_RECIPE.header
}
pub const fn table_body_classes() -> &'static str {
TABLE_RECIPE.body
}
pub const fn table_footer_classes() -> &'static str {
TABLE_RECIPE.footer
}
pub const fn table_row_classes() -> &'static str {
TABLE_RECIPE.row
}
pub const fn table_head_classes() -> &'static str {
TABLE_RECIPE.head
}
pub const fn table_cell_classes() -> &'static str {
TABLE_RECIPE.cell
}
pub const fn table_caption_classes() -> &'static str {
TABLE_RECIPE.caption
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_every_named_slot() {
let recipe = table_recipe();
assert!(recipe.container.contains("overflow-auto"));
assert!(recipe.root.contains("caption-bottom"));
assert!(recipe.header.contains("[&_tr]:border-b"));
assert!(recipe.body.contains("last-child"));
assert!(recipe.footer.contains("bg-muted/50"));
assert!(recipe.row.contains("data-[state=selected]"));
assert!(recipe.head.contains("text-muted-foreground"));
assert!(recipe.cell.contains("align-middle"));
assert!(recipe.caption.contains("mt-4"));
}
#[test]
fn recipe_is_const_and_copy() {
const STYLE: TableStyle = TABLE_RECIPE.resolve(TableRecipeProps);
let copied = STYLE;
assert!(copied.recipe.root.contains("w-full"));
}
#[test]
fn recipe_metadata_exposes_the_complete_anatomy() {
let metadata = crate::variant_spec::component_metadata(table_recipe());
let anatomy = metadata.anatomy.as_ref().unwrap();
assert_eq!(metadata.name, "Table");
assert!(metadata.variants.is_empty());
assert_eq!(anatomy.scope, "table");
assert_eq!(anatomy.parts, super::super::TABLE_ANATOMY.parts());
metadata.validate().unwrap();
}
}
table.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Table component implementation
//!
//! Renders a responsive data table with header, body, footer, and caption sections.
use super::recipe::{TableRecipeProps, TABLE_RECIPE};
use super::TABLE_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Table component - a responsive data table wrapped in an overflow container
pub struct Table<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The table content
pub children: Option<C>,
}
impl Table<()> {
pub const fn new() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<C> Default for Table<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for Table<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = TABLE_RECIPE.resolve(TableRecipeProps);
html_sync! {
<div
data-scope={TABLE_ANATOMY.scope()}
data-part="container"
data-slot="table-container"
class={TABLE_RECIPE.container}
>
<table
data-table
data-scope={TABLE_ANATOMY.scope()}
data-part="root"
data-slot="table"
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</table>
</div>
}
.render(cx)
}
}
/// TableHeader component - groups header rows
pub struct TableHeader<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The header content
pub children: Option<C>,
}
impl<C> Default for TableHeader<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TableHeader<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<thead
data-table-header
data-scope={TABLE_ANATOMY.scope()}
data-part="header"
data-slot="table-header"
class={proa_macros::text!("{} {}", TABLE_RECIPE.header, self.class)}
>
{self.children}
</thead>
}
.render(cx)
}
}
/// TableBody component - groups body rows
pub struct TableBody<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The body content
pub children: Option<C>,
}
impl<C> Default for TableBody<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TableBody<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<tbody
data-table-body
data-scope={TABLE_ANATOMY.scope()}
data-part="body"
data-slot="table-body"
class={proa_macros::text!("{} {}", TABLE_RECIPE.body, self.class)}
>
{self.children}
</tbody>
}
.render(cx)
}
}
/// TableFooter component - groups footer rows
pub struct TableFooter<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The footer content
pub children: Option<C>,
}
impl<C> Default for TableFooter<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TableFooter<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<tfoot
data-table-footer
data-scope={TABLE_ANATOMY.scope()}
data-part="footer"
data-slot="table-footer"
class={proa_macros::text!("{} {}", TABLE_RECIPE.footer, self.class)}
>
{self.children}
</tfoot>
}
.render(cx)
}
}
/// TableRow component - a single table row
pub struct TableRow<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The row content
pub children: Option<C>,
}
impl<C> Default for TableRow<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TableRow<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<tr
data-table-row
data-scope={TABLE_ANATOMY.scope()}
data-part="row"
data-slot="table-row"
class={proa_macros::text!("{} {}", TABLE_RECIPE.row, self.class)}
>
{self.children}
</tr>
}
.render(cx)
}
}
/// TableHead component - a header cell
pub struct TableHead<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The header cell content
pub children: Option<C>,
}
impl<C> Default for TableHead<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TableHead<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<th
data-table-head
data-scope={TABLE_ANATOMY.scope()}
data-part="head"
data-slot="table-head"
class={proa_macros::text!("{} {}", TABLE_RECIPE.head, self.class)}
>
{self.children}
</th>
}
.render(cx)
}
}
/// TableCell component - a data cell
pub struct TableCell<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The cell content
pub children: Option<C>,
}
impl<C> Default for TableCell<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TableCell<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<td
data-table-cell
data-scope={TABLE_ANATOMY.scope()}
data-part="cell"
data-slot="table-cell"
class={proa_macros::text!("{} {}", TABLE_RECIPE.cell, self.class)}
>
{self.children}
</td>
}
.render(cx)
}
}
/// TableCaption component - a table caption
pub struct TableCaption<C = ()> {
/// Additional CSS classes
pub class: Option<&'static str>,
/// The caption content
pub children: Option<C>,
}
impl<C> Default for TableCaption<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for TableCaption<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<caption
data-table-caption
data-scope={TABLE_ANATOMY.scope()}
data-part="caption"
data-slot="table-caption"
class={proa_macros::text!("{} {}", TABLE_RECIPE.caption, self.class)}
>
{self.children}
</caption>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn table_renders_with_wrapper_div() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Table {
class: None,
children: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("relative w-full overflow-auto"));
assert!(html.contains("<table"));
assert!(html.contains("data-table"));
assert!(html.contains("data-slot=\"table-container\""));
assert!(html.contains("data-slot=\"table\""));
assert!(html.contains("Content"));
}
#[test]
fn table_has_base_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Table {
class: None,
children: Some(text("Table")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("w-full"));
assert!(html.contains("caption-bottom"));
assert!(html.contains("text-sm"));
}
#[test]
fn table_accepts_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Table {
class: Some("custom-class"),
children: Some(text("Custom")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("custom-class"));
}
#[test]
fn table_header_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TableHeader {
class: None,
children: Some(text("Header")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<thead"));
assert!(html.contains("data-table-header"));
assert!(html.contains("data-slot=\"table-header\""));
assert!(html.contains("Header"));
}
#[test]
fn table_body_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TableBody {
class: None,
children: Some(text("Body")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<tbody"));
assert!(html.contains("data-table-body"));
assert!(html.contains("data-slot=\"table-body\""));
assert!(html.contains("Body"));
}
#[test]
fn table_footer_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TableFooter {
class: None,
children: Some(text("Footer")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<tfoot"));
assert!(html.contains("data-table-footer"));
assert!(html.contains("data-slot=\"table-footer\""));
assert!(html.contains("Footer"));
assert!(html.contains("bg-muted/50"));
assert!(html.contains("border-t"));
}
#[test]
fn table_row_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TableRow {
class: None,
children: Some(text("Row")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<tr"));
assert!(html.contains("data-table-row"));
assert!(html.contains("data-slot=\"table-row\""));
assert!(html.contains("Row"));
assert!(html.contains("border-b"));
}
#[test]
fn table_head_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TableHead {
class: None,
children: Some(text("Column")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<th"));
assert!(html.contains("data-table-head"));
assert!(html.contains("data-slot=\"table-head\""));
assert!(html.contains("Column"));
assert!(html.contains("font-medium"));
assert!(html.contains("text-muted-foreground"));
}
#[test]
fn table_cell_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TableCell {
class: None,
children: Some(text("Cell")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<td"));
assert!(html.contains("data-table-cell"));
assert!(html.contains("data-slot=\"table-cell\""));
assert!(html.contains("Cell"));
assert!(html.contains("p-2"));
assert!(html.contains("align-middle"));
}
#[test]
fn table_caption_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
TableCaption {
class: None,
children: Some(text("A list of items")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<caption"));
assert!(html.contains("data-table-caption"));
assert!(html.contains("data-slot=\"table-caption\""));
assert!(html.contains("A list of items"));
assert!(html.contains("text-muted-foreground"));
}
#[test]
fn full_table_composition() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let header_cells = {
html_sync! {
{TableHead { class: None, children: Some(text("Invoice")), }}
{TableHead { class: None, children: Some(text("Amount")), }}
}
};
let header = TableHeader {
class: None,
children: Some(TableRow {
class: None,
children: Some(header_cells),
}),
};
let body_cells = {
html_sync! {
{TableCell { class: None, children: Some(text("INV001")), }}
{TableCell { class: None, children: Some(text("$250.00")), }}
}
};
let body = TableBody {
class: None,
children: Some(TableRow {
class: None,
children: Some(body_cells),
}),
};
let footer_cells = {
html_sync! {
{TableCell { class: None, children: Some(text("Total")), }}
{TableCell { class: None, children: Some(text("$250.00")), }}
}
};
let footer = TableFooter {
class: None,
children: Some(TableRow {
class: None,
children: Some(footer_cells),
}),
};
let children = {
html_sync! {
{TableCaption { class: None, children: Some(text("A list of recent invoices.")), }}
{header}
{body}
{footer}
}
};
Table {
class: None,
children: Some(children),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-table"));
assert!(html.contains("data-table-caption"));
assert!(html.contains("data-table-header"));
assert!(html.contains("data-table-body"));
assert!(html.contains("data-table-footer"));
assert!(html.contains("data-table-row"));
assert!(html.contains("data-table-head"));
assert!(html.contains("data-table-cell"));
assert!(html.contains("A list of recent invoices."));
assert!(html.contains("Invoice"));
assert!(html.contains("Amount"));
assert!(html.contains("INV001"));
assert!(html.contains("$250.00"));
assert!(html.contains("Total"));
assert!(html.contains(r#"data-scope="table" data-part="container""#));
assert!(html.contains(r#"data-scope="table" data-part="root""#));
for part in ["caption", "header", "body", "footer", "row", "head", "cell"] {
assert!(html.contains(&format!(r#"data-part="{part}""#)));
}
}
}