Data Table
Poetry's data table is server-driven: the composed table plus sortable headers, a filter box, and pagination, with sorting, filtering, and paging carried as URL state. Shareable, crawlable, and back-button-correct, because GET is the only transport the back button can replay. Your controller owns the data; the component owns the markup, the URLs, and the accessibility.
The controller
State.from_params sanitizes at the door:
sort survives only if it appears in the
sortable: whitelist and dir only as
asc or desc, so
state.order_clause is injection-safe
by construction, never by caller discipline. The
filter query is stripped (nil when blank) and the page floors at 1.
class NotesController < ApplicationController
PER = 20
def index
@state = Poetry::Ui::DataTable::State.from_params(
params, sortable: %w[title created_at], default_sort: "created_at", default_dir: "desc"
)
scope = Note.all
scope = scope.where("title LIKE ?", "%#{Note.sanitize_sql_like(@state.q)}%") if @state.q
@total = (scope.count / PER.to_f).ceil
@notes = scope.order(@state.order_clause).offset((@state.page - 1) * PER).limit(PER)
end
end
total: is the number of pages, not rows; the
sample computes it with a ceiling division. Leave it nil (or pass 1)
and the pagination footer does not render.
The view
Columns are declared in the block and rendered per row:
<%= poetry_data_table(rows: @notes, state: @state, total: @total,
caption: "Your notes, most recent first.",
path: ->(p) { notes_path(**p) }) do |table| %>
<% table.with_column("Title", key: :title, sortable: true) { |note| note.title } %>
<% table.with_column("Created", key: :created_at, sortable: true) { |note| l(note.created_at.to_date) } %>
<% end %>
-
path:is a callable from a params hash to a URL (the pagination convention). Called with an empty hash it must return the bare collection URL, which becomes the filter form's action. -
Cell blocks return the cell content
(
{ |note| note.title }); they must not write to the template buffer. Compose Poetry helpers for rich cells; they return safe strings. -
A sortable column whose
key:is missing from the controller'ssortable:whitelist raises at render, so view and controller drift surfaces immediately, not as a silently dead header. -
Give the table a
caption:; it is the table's accessible purpose.
Sticky headers
sticky_header: true pins the header row while the
table's scroll container scrolls, and container_class:
caps that container's height. Without a cap nothing sticks: the
container just grows. The sticky scroll region needs an accessible
name; scroll_label: provides one and falls back to
caption:.
<%= poetry_data_table(rows: @notes, state: @state, total: @total,
caption: "Your notes, most recent first.",
sticky_header: true, container_class: "max-h-96",
path: ->(p) { notes_path(**p) }) do |table| %>
...
<% end %>
Row selection
selectable: takes a lambda mapping each row to its id
and turns the feature on: a leading checkbox column with select-all
(a real indeterminate middle state), shift-click ranges, and count
announcements. The checkboxes are the form value: plain checkboxes
named selected_ids[] (rename via
selection_name:) whose values come from your lambda,
readable by any form they sit in. Pair with the
action bar block for
bulk actions.
<%= poetry_data_table(rows: @notes, state: @state, total: @total,
caption: "Your notes, most recent first.",
selectable: ->(note) { note.id },
path: ->(p) { notes_path(**p) }) do |table| %>
...
<% end %>
Scoped updates (optional)
Pass frame: "notes" to wrap the table in a
turbo-frame that advances the URL: hosts with Turbo
swap only the table while the address bar still updates. Your
response must render the same frame id. Without Turbo the frame
element is inert and every link still works as a full navigation.
<%= poetry_data_table(rows: @notes, state: @state, total: @total,
caption: "Your notes, most recent first.",
frame: "notes",
path: ->(p) { notes_path(**p) }) do |table| %>
...
<% end %>
Filter as you type (optional)
The filter submits on Enter. For live filtering, add a small debounced Stimulus controller in your app that submits the form as the user types, keeping the same GET form; the URL contract is unchanged.
Accessibility
The active column's header cell carries
aria-sort="ascending|descending", one column at a time.
Sort affordances are real links, so keyboard, middle-click, and
copy-link all work. The filter is a labelled GET form with the
search role; Enter submits natively, zero JS.
Row mutations belong to another tier
Sorting, filtering, and paging are view state and belong in the URL. Row mutations (inline edit, toggles, row actions) belong to Poetry's reactive tier: render a reactive component inside a cell and the two tiers compose. The table's GET round trip re-renders the collection; the row's signed action mutates one record and replaces one row by id. Each tier owns the state that belongs to it: the URL for the view, the database for the data.
The per-option reference, with live examples of filtering, selection, empty states, and cell formatting, lives on the component page.