Data Table
A table with sorting, row selection, and sticky headers.
Installation
Included in poetry-ui — available as
poetry_data_table
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add data-table
Default
<% rows = [
{ number: "INV-001", customer: "Acme", amount: "$250.00" },
{ number: "INV-002", customer: "Globex", amount: "$1,150.00" },
{ number: "INV-003", customer: "Initech", amount: "$420.00" }
]
state = Poetry::Ui::DataTable::State.from_params(
{ sort: "number", dir: "asc" }, sortable: %w[number customer]
) %>
<div class="w-full max-w-2xl">
<%= poetry_data_table(rows: rows, state: state, path: ->(params) { "?#{params.to_query}" },
caption: "A list of recent invoices.") do |table| %>
<% table.with_column("Invoice", key: :number, sortable: true) { |row| row[:number] } %>
<% table.with_column("Customer", key: :customer, sortable: true, classes: "text-center") { |row| row[:customer] } %>
<% table.with_column("Amount", classes: "text-right") { |row| row[:amount] } %>
<% end %>
</div>
Cell formatting
<% rows = [
{ order: "ORD-4471", channel: "Web", status: :success, total: "$312.40" },
{ order: "ORD-4472", channel: "Retail", status: :warning, total: "$88.00" },
{ order: "ORD-4473", channel: "Web", status: :info, total: "$1,204.90" }
]
labels = { success: "Fulfilled", warning: "Backordered", info: "Processing" }
state = Poetry::Ui::DataTable::State.from_params(
{ sort: "order", dir: "asc" }, sortable: %w[order channel]
) %>
<div class="w-full max-w-2xl">
<%= poetry_data_table(rows: rows, state: state, path: ->(params) { "?#{params.to_query}" },
caption: "Recent orders with status pills and right-aligned totals.") do |table| %>
<% table.with_column("Order", key: :order, sortable: true) { |row| row[:order] } %>
<% table.with_column("Channel", key: :channel, sortable: true, classes: "text-center") { |row| row[:channel] } %>
<% table.with_column("Status", classes: "text-center") { |row| poetry_badge(variant: row[:status]) { labels[row[:status]] } } %>
<% table.with_column("Total", classes: "text-right tabular-nums") { |row| row[:total] } %>
<% end %>
</div>
Empty
<% state = Poetry::Ui::DataTable::State.from_params(
{ q: "zzz" }, sortable: %w[number customer]
) %>
<div class="w-full max-w-2xl">
<%= poetry_data_table(rows: [], state: state, path: ->(params) { "?#{params.to_query}" },
caption: "A list of recent invoices.") do |table| %>
<% table.with_column("Invoice", key: :number, sortable: true) { |row| row[:number] } %>
<% table.with_column("Customer", key: :customer, sortable: true, classes: "text-center") { |row| row[:customer] } %>
<% table.with_column("Amount", classes: "text-right") { |row| row[:amount] } %>
<% end %>
</div>
Filtered and paginated
<% rows = [
{ number: "INV-001", customer: "Acme", amount: "$250.00" },
{ number: "INV-002", customer: "Globex", amount: "$1,150.00" }
]
state = Poetry::Ui::DataTable::State.from_params(
{ q: "inv", sort: "customer", dir: "desc", page: 2 }, sortable: %w[number customer]
) %>
<div class="w-full max-w-2xl">
<%= poetry_data_table(rows: rows, state: state, total: 3,
path: ->(params) { "?#{params.to_query}" },
caption: "A list of recent invoices.") do |table| %>
<% table.with_column("Invoice", key: :number, sortable: true) { |row| row[:number] } %>
<% table.with_column("Customer", key: :customer, sortable: true, classes: "text-center") { |row| row[:customer] } %>
<% table.with_column("Amount", classes: "text-right") { |row| row[:amount] } %>
<% end %>
</div>
Row actions
<%# The upstream actions-column recipe: each row's controls live behind a
compact ellipsis trigger in the trailing column. The header keeps an
sr-only label so the column stays named for screen readers. %>
<% rows = [
{ number: "INV-001", customer: "Acme", amount: "$250.00" },
{ number: "INV-002", customer: "Globex", amount: "$1,150.00" },
{ number: "INV-003", customer: "Initech", amount: "$420.00" }
]
state = Poetry::Ui::DataTable::State.from_params(
{ sort: "number", dir: "asc" }, sortable: %w[number customer]
) %>
<div class="w-full max-w-2xl">
<%= poetry_data_table(rows: rows, state: state, path: ->(params) { "?#{params.to_query}" },
caption: "Invoices with per-row actions.") do |table| %>
<% table.with_column("Invoice", key: :number, sortable: true) { |row| row[:number] } %>
<% table.with_column("Customer", key: :customer, sortable: true, classes: "text-center") { |row| row[:customer] } %>
<% table.with_column("Amount", classes: "text-right") { |row| row[:amount] } %>
<% table.with_column(tag.span("Actions", class: "sr-only"), classes: "w-12 text-right") do |row| %>
<%= poetry_dropdown_menu(align: :end) do |menu| %>
<% menu.with_trigger(variant: :ghost, size: :icon, label: "Open actions for #{row[:number]}") do %>
<%= poetry_icon(name: :ellipsis) %>
<% end %>
<% menu.with_label { "Actions" } %>
<% menu.with_item(shortcut: "⌘C") { "Copy invoice ID" } %>
<% menu.with_separator %>
<% menu.with_item { "View customer" } %>
<% menu.with_item { "View invoice details" } %>
<% end %>
<% end %>
<% end %>
</div>
Selectable
<% rows = [
{ id: "MBR-01", name: "Jordan Lee", role: "Editor" },
{ id: "MBR-02", name: "Priya Nair", role: "Admin" },
{ id: "MBR-03", name: "Marco Silva", role: "Viewer" }
]
state = Poetry::Ui::DataTable::State.from_params(
{ sort: "name", dir: "asc" }, sortable: %w[name role]
) %>
<div class="w-full max-w-2xl">
<%= poetry_data_table(rows: rows, state: state, path: ->(params) { "?#{params.to_query}" },
caption: "Team members you can select for bulk actions.",
selectable: ->(row) { row[:id] }) do |table| %>
<% table.with_column("Member", key: :name, sortable: true) { |row| row[:name] } %>
<% table.with_column("Role", key: :role, sortable: true, classes: "text-center") { |row| row[:role] } %>
<% table.with_column("ID", classes: "text-right") { |row| row[:id] } %>
<% end %>
</div>
API
Poetry::Ui::DataTable::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| caption: | String | The table's accessible purpose, rendered as its <caption>. | |
| container_class: | String | Caps the scroll container's height (e.g. \"max-h-96\") - without a cap the sticky header has nothing to stick inside. | |
| empty_text: | String | defaults to "No results." |
Shown in a full-width row when rows are empty. |
| filter: | Boolean | defaults to true |
Renders the filter form; false drops the toolbar row. |
| filter_label: | String | defaults to "Filter" |
The filter input's accessible label. |
| filter_name: | String | defaults to "q" |
The query-param key the filter submits under. |
| filter_placeholder: | String | defaults to "Filter…" |
The filter input's placeholder text. |
| frame: | String | Wrap in a <turbo-frame data-turbo-action=\"advance\"> so hosts with Turbo scope the round trip to the table while the URL still advances. The host response must render the same frame id. | |
| scroll_label: | String | Accessible name for the sticky scroll region; falls back to caption:. | |
| selectable: | Object | Row selection: a lambda mapping each row to its id turns the feature ON - a leading checkbox column (select-all with a real indeterminate middle state, shift ranges, count announcements) whose checkboxes ARE the form value (selection_name[], plain checkboxes with no JS). Pair with the action-bar block for bulk actions. | |
| selection_name: | String | defaults to "selected_ids" |
The checkbox field name; selected row ids post as selection_name[]. |
| sticky_header: | Boolean | defaults to false |
Forwarded to the inner Table: sticky_header pins the thead while the table's scroll container scrolls; container_class caps that container's height (\"max-h-96\") - without a cap nothing sticks. The sticky scroll region needs an accessible name (the ScrollArea rule); scroll_label: falls back to caption:. |
Slots
| Writer | Description |
|---|---|
| with_column | Columns are DECLARED here and rendered per row by the template. A sortable column's key must be in the state's whitelist - catching drift between the view's columns and the controller's sortable: list at render, not as a silently unsortable header. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=data-table] | Root surface - toolbar, table, and pagination footer stack here |
| [data-slot=data-table-toolbar] | The row above the table holding the filter form - renders unless filter: false |
| [data-slot=data-table-filter] | The GET filter form (role=search) - hidden fields carry the current sort; a new filter resets the page |
| [data-slot=table-container] | The composed Table's scroll container - Table renders it, this surface owns where it sits |
| [data-slot=data-table-footer] | The Pagination row - renders when total: is more than one page |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root (if selectable?) | poetry--core--table-selection | registers · value label |
| select_all | poetry--core--table-selection | toggleAll on change · target all |
| row_checkbox | poetry--core--table-selection | press on pointerdown/keydown · toggled on change |