Table
Composable, semantic table primitives (shadcn parity). Static, no JS.
Installation
bin/rails g wabi:add tableExample
| Invoice | Status | Amount |
|---|---|---|
| INV001 | Paid | $250.00 |
| INV002 | Pending | $150.00 |
render Components::UI::Table.new do
render Components::UI::TableCaption.new { "A list of your recent invoices." }
render Components::UI::TableHeader.new do
render Components::UI::TableRow.new do
render Components::UI::TableHead.new { "Invoice" }
render Components::UI::TableHead.new { "Status" }
render Components::UI::TableHead.new(class: "text-right") { "Amount" }
end
end
render Components::UI::TableBody.new do
render Components::UI::TableRow.new do
render Components::UI::TableCell.new(class: "font-medium") { "INV001" }
render Components::UI::TableCell.new { "Paid" }
render Components::UI::TableCell.new(class: "text-right") { "$250.00" }
end
render Components::UI::TableRow.new do
render Components::UI::TableCell.new(class: "font-medium") { "INV002" }
render Components::UI::TableCell.new { "Pending" }
render Components::UI::TableCell.new(class: "text-right") { "$150.00" }
end
end
end
Source
app/components/ui/table.rb
# frozen_string_literal: true
module Components
module UI
class Table < Wabi::Base
variants do
base "w-full caption-bottom text-sm"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&)
user_class = @attrs.delete(:class)
div(class: "relative w-full overflow-auto") do
table(**@attrs, class: merge_class(tokens, user_class), &)
end
end
end
end
end
app/components/ui/table_header.rb
# frozen_string_literal: true
module Components
module UI
class TableHeader < Wabi::Base
variants do
base "[&_tr]:border-b"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&)
user_class = @attrs.delete(:class)
thead(**@attrs, class: merge_class(tokens, user_class), &)
end
end
end
end
app/components/ui/table_body.rb
# frozen_string_literal: true
module Components
module UI
class TableBody < Wabi::Base
variants do
base "[&_tr:last-child]:border-0"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&)
user_class = @attrs.delete(:class)
tbody(**@attrs, class: merge_class(tokens, user_class), &)
end
end
end
end
app/components/ui/table_footer.rb
# frozen_string_literal: true
module Components
module UI
class TableFooter < Wabi::Base
variants do
base "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&)
user_class = @attrs.delete(:class)
tfoot(**@attrs, class: merge_class(tokens, user_class), &)
end
end
end
end
app/components/ui/table_row.rb
# frozen_string_literal: true
module Components
module UI
class TableRow < Wabi::Base
variants do
base "border-b transition-colors motion-reduce:transition-none hover:bg-muted/50 data-[state=selected]:bg-muted"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&)
user_class = @attrs.delete(:class)
# Reflect selection state programmatically so AT users know when a row
# is selected (data-state="selected" drives the visual via Tailwind;
# aria-selected carries the same signal to screen readers).
# Callers mark rows with data-state: "selected"; parent containers
# should carry role="grid" or rows role="row" per ARIA grid pattern.
aria_selected = @attrs[:'data-state'] == "selected" ? "true" : nil
tr(aria_selected: aria_selected, **@attrs, class: merge_class(tokens, user_class), &)
end
end
end
end
app/components/ui/table_head.rb
# frozen_string_literal: true
module Components
module UI
class TableHead < Wabi::Base
variants do
base "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0"
end
# +scope+ defaults to "col" so screen readers can associate header cells
# with data cells. Override with scope: "row" for row-header <th> cells.
def initialize(scope: "col", **attrs)
@attrs = attrs.merge(scope: scope)
end
def view_template(&)
user_class = @attrs.delete(:class)
th(**@attrs, class: merge_class(tokens, user_class), &)
end
end
end
end
app/components/ui/table_cell.rb
# frozen_string_literal: true
module Components
module UI
class TableCell < Wabi::Base
variants do
base "p-4 align-middle [&:has([role=checkbox])]:pr-0"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&)
user_class = @attrs.delete(:class)
td(**@attrs, class: merge_class(tokens, user_class), &)
end
end
end
end
app/components/ui/table_caption.rb
# frozen_string_literal: true
module Components
module UI
class TableCaption < Wabi::Base
variants do
base "mt-4 text-sm text-muted-foreground"
end
def initialize(**attrs) = @attrs = attrs
def view_template(&)
user_class = @attrs.delete(:class)
caption(**@attrs, class: merge_class(tokens, user_class), &)
end
end
end
end
Accessibility
- Uses native table elements (table/thead/tbody/tfoot/tr/th/td/caption), so screen readers get the table semantics for free.
- TableHead renders a th; add scope: "col" / scope: "row" via attrs when the header relationship isn't obvious.
- TableCaption gives the table an accessible name — prefer it over a separate heading.