Data table
Sortable, paged and filter-aware, driven by one State struct. Wire it to events or to the URL - the two live demos below run the same free in-memory engine, one per mode.
Event mode
Every interaction pushes a single op-grammar event, the handler applies it with State helpers and re-runs the engine. Sort this one and the URL stays put - the state lives on the server.
Link mode
The same rows and engine, driven entirely by the URL: sort, filter, search and page
all patch State-encoded query params, handle_params
decodes them back through the field whitelist and re-runs the engine. No events -
every view is a link you can share, bookmark or curl. No selection or column
controls here either: those are UI state, and UI state rides events, never URLs.
Try sorting, then reload.
| Status | |||
|---|---|---|---|
| Kim | kim@example.com | refunded | $327 |
| Lou | lou@example.com | paid | $64 |
| Mia | mia@example.com | pending | $201 |
| Ned | ned@example.com | refunded | $338 |
| Ola | ola@example.com | paid | $75 |
| Pax | pax@example.com | pending | $212 |
| Quin | quin@example.com | refunded | $349 |
| Rae | rae@example.com | paid | $86 |
| Sol | sol@example.com | pending | $223 |
| Tia | tia@example.com | refunded | $360 |
The state this demo decoded from the URL:
%PetalComponents.DataTable.State{
order_by: [],
filters: [],
search: nil,
page: 2,
page_size: 10,
total: 20
}
Sorted, paged, engine-run
The whole surface from one State struct: sortable headers (aria-sort included), the range summary, and pagination that picks numbered mode because total is known. This static example runs the free in-memory engine over 20 rows at render time - zero setup, no database. In your app the same State drives handle_params (link mode) or a single op-grammar event (event mode).
| Tia | tia@example.com | $360 |
| Quin | quin@example.com | $349 |
| Ned | ned@example.com | $338 |
| Kim | kim@example.com | $327 |
| Gus | gus@example.com | $316 |
<% state = %State{order_by: [amount: :desc], page: 1, page_size: 5} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table id="sx-dt-basic" rows={rows} state={state} path="#">
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} sortable align="right">${row.amount}</:col>
</.data_table>
The full toolbar: search, typed filters, per-page
searchable sweeps string fields case-insensitively before filters (totals stay honest); filterable columns get buttons that open typed popover editors - operator + value for text/number/date (between reveals its second bound, no JS), a checkbox list for select - and read their predicate while active, with an inline clear. page_size_options adds the per-page select; Reset filters appears while any filter is active. Event mode posts one op-grammar event for all of it (State.handle_op/3 is the whole handler); link mode patches curl-able URLs.
<% state = %State{
search: "i",
filters: [%{field: :status, op: :in, value: ["pending", "refunded"]}],
page_size: 5
} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-toolbar"
rows={rows}
state={state}
on_change="table"
searchable
page_size_options={[5, 10, 20]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email} filterable="text">{row.email}</:col>
<:col
:let={row}
field={:status}
filterable="select"
options={[{"Paid", "paid"}, {"Pending", "pending"}, {"Refunded", "refunded"}]}
>
<.badge
size="sm"
variant="soft"
color={
case row.status do
"paid" -> "success"
"pending" -> "warning"
_ -> "danger"
end
}
label={row.status}
/>
</:col>
<:col :let={row} field={:amount} sortable align="right" filterable="number">
${row.amount}
</:col>
</.data_table>
Row selection with a morphing toolbar
selectable adds a checkbox column keyed by row_id (a field or a function - it must uniquely identify records across ALL pages). The header checkbox is tri-state; while rows are selected the toolbar morphs into the count, your :bulk_action slot (:let receives the ids) and a clear button. Selection is UI state: it rides the on_ui event (defaulting to on_change) with select / select_all / clear_selection ops and never touches URLs - a MapSet plus three clauses is the whole backend.
<% state = %State{page_size: 4} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-selection"
rows={rows}
state={state}
on_change="table"
selectable
selected={["1", "3"]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} align="right">${row.amount}</:col>
<:bulk_action :let={ids}>
<.button size="sm" variant="soft" color="danger">Archive {length(ids)}</.button>
</:bulk_action>
</.data_table>
Columns visibility and order
column_toggle renders a Columns dropdown - every declared column with a checkbox, toggling immediately (the panel stays open for multi-toggling). reorderable adds move controls posting a field + dir delta; DataTable.move_column/4 applies it to your current order in one line, race-free under rapid clicks. Both are presentation state on the on_ui event, never in URLs; the last visible column can't be hidden.
<% state = %State{page_size: 4} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-columns"
rows={rows}
state={state}
on_change="table"
column_toggle
reorderable
hidden_columns={["email"]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} align="right">${row.amount}</:col>
</.data_table>
Loading skeletons
loading swaps the page for skeleton rows - one per page_size row, respecting column count and alignment. Flip it off when the query resolves.
| Name | Amount | |
|---|---|---|
<% state = %State{total: 74, page_size: 4} %>
<.data_table id="sx-dt-loading" rows={[]} state={state} path="#" loading>
<:col :let={row} field={:name}>{row}</:col>
<:col :let={row} field={:email}>{row}</:col>
<:col :let={row} field={:amount} align="right">{row}</:col>
</.data_table>
The filters-aware empty state
An empty result set with active filters says so and offers the way out - a clear-filters patch link in link mode, the op-grammar event in event mode. Without filters it is a plain no-results line. Override either with the :empty slot.
| Name |
|---|
<% state = %State{total: 0, filters: [%{field: :name, op: :contains, value: "zz"}]} %>
<.data_table id="sx-dt-empty" rows={[]} state={state} path="#">
<:col :let={row} field={:name}>{row}</:col>
<:col :let={row} field={:email}>{row}</:col>
</.data_table>
| Attribute | Type | Default | Description |
|---|---|---|---|
actions_label
|
string |
"Actions"
|
the actions column's header, announced but not shown |
apply_label
|
string |
"Apply"
|
the filter editors' submit label, localizable |
class
|
any |
nil
|
|
clear_filters_label
|
string |
"Clear filters"
|
|
clear_selection_label
|
string |
"Clear selection"
|
|
column_order
|
list |
[]
|
fields in display order (atoms or strings) - presentation state on the `on_ui` event, like `hidden_columns`, never in URLs. Fields not listed keep their declared order after the listed ones. Empty means the declared `:col` order. |
column_toggle
|
boolean |
false
|
render a columns-visibility dropdown in the toolbar (rides the `on_ui` event) |
column_toggle_label
|
string |
"Columns"
|
|
density
|
string |
"comfortable"
|
one of: "comfortable", "compact"
|
filter_op_labels
|
map |
%{}
|
overrides for the operator display names, e.g. %{contains: "enthält"} |
filter_options_placeholder
|
string |
"Filter options…"
|
the select editor's option-filter placeholder (shown from 8 options up), localizable |
hidden_columns
|
list |
[]
|
fields currently hidden (atoms or strings) - presentation state, never in URLs |
id*
|
string |
||
loading
|
boolean |
false
|
render skeleton rows instead of data |
max_height
|
string |
nil
|
caps the table body's height (any CSS length), making it scroll under a pinned header |
move_down_label
|
string |
"Move down"
|
|
move_up_label
|
string |
"Move up"
|
reorder buttons, localizable |
no_filtered_results_text
|
string |
"No results for these filters"
|
the empty message while filters are active |
no_results_text
|
string |
"No results"
|
|
of_label
|
string |
"of"
|
the range summary's connective, localizable |
on_change
|
string |
nil
|
event mode: the event every table interaction pushes, with an op-shaped payload (`op` of "sort" | "page" | "clear_filters"). |
on_ui
|
string |
nil
|
the event UI-state ops (selection) push, both wiring modes. Defaults to `on_change`; required alongside `selectable` in link mode. |
page_label
|
string |
"Page"
|
cursor mode's page word, localizable |
page_size_options
|
list |
[]
|
when non-empty, render a rows-per-page select in the footer |
path
|
string |
nil
|
link mode: the base path sort and page changes patch to, with the state encoded as query params. Required unless `on_change` is set. |
per_page_label
|
string |
"Per page"
|
the rows-per-page label, localizable |
reorderable
|
boolean |
false
|
render move up/down controls in the Columns menu (requires `column_toggle`) |
reset_filters_label
|
string |
"Reset filters"
|
|
results_label
|
string |
"results"
|
the announced result-count noun, localizable |
row_id
|
any |
:id
|
row identity for selection: a field (atom) or a 1-arity function of the row. The key must uniquely identify a record across ALL pages, not just the visible one - a selection retained while paging is only as sound as this key. Same-page duplicates raise. |
row_label
|
any |
nil
|
a 1-arity function returning a human name for a row, used as the selection checkbox's accessible name. Without it the checkbox announces its position ("Select row 3"), because a primary key - a UUID, say - is not something a screen reader user can act on. |
rows
|
list |
[]
|
|
search_debounce
|
integer |
300
|
quick-search debounce in ms, both wiring modes |
search_placeholder
|
string |
"Search…"
|
|
searchable
|
boolean |
false
|
render the quick-search input in the toolbar (drives `state.search`) |
select_all_label
|
string |
"Select all rows"
|
the header checkbox's aria-label |
select_row_label
|
string |
"Select row"
|
the row checkbox's aria-label prefix, localizable |
selectable
|
boolean |
false
|
render a leading checkbox column |
selected
|
list |
[]
|
the currently selected row ids (any terms; compared as strings) |
selected_label
|
string |
"selected"
|
the selection count's word, localizable |
state*
|
{:struct, PetalComponents.DataTable.State} |
||
sticky_header
|
boolean |
false
|
pin the header row. Inside a data table this needs `max_height` too: the scroll region is what the header sticks to, and a wrapper that scrolls only sideways cannot pin anything. |
striped
|
boolean |
false
|
|
target
|
any |
nil
|
event mode: the phx-target (e.g. @myself) |
variant
|
string |
"basic"
|
one of: "ghost", "basic"
|
:action
slot
|
slot |
trailing actions column, `:let` receives the row | |
:bulk_action
slot
|
slot |
toolbar content while rows are selected; `:let` receives the selected ids | |
:col
slot
|
slot |
||
:empty
slot
|
slot |
custom empty state; a filters-aware default renders otherwise | |
:toolbar
slot
|
slot |
custom toolbar content rendered above the table |