A full data table composed around PetalComponents.Table: sortable
columns, paging, loading and empty states - driven entirely by a
PetalComponents.DataTable.State.
Two wiring modes, like pagination:
link mode (default, pass
path): every sort and page change is apatchURL built viaState.to_params/1. State is shareable, the back button works, andhandle_params+State.from_params/2is the whole backend:def handle_params(params, _uri, socket) do state = State.from_params(params, fields: [:name, :email]) {rows, state} = Engine.List.run(all_rows(), state) {:noreply, assign(socket, rows: rows, table: state)} endevent mode (pass
on_change): one event, one grammar. Sorts arrive as%{"op" => "sort", "field" => f}, page changes as%{"op" => "page", "page" => n}, quick-search as%{"op" => "search", "term" => t}, page-size changes as%{"op" => "page_size", "page_size" => n}, filter edits as%{"op" => "filter", "field" => f, ...editor inputs}, filter clears as%{"op" => "clear_filters"}.State.handle_op/3speaks the whole grammar, so the handler is a one-liner.
In link mode the quick-search input and rows-per-page select are wired
by the PetalDataTable hook (patch URLs built from templates the
component renders); event mode needs no JS.
Selection (selectable) is UI state, not query state - it rides an
event in BOTH wiring modes (on_ui, defaulting to on_change) and
never touches URLs. Its ops sit outside State: select (id),
select_all, clear_selection, toggle_column and move_column
(a field + dir delta, applied to your current order with
move_column/4 - one line in the handler, race-free under rapid
clicks) - a MapSet plus a handful of clauses is the whole backend. Selection is keyed by row_id, which must uniquely
identify a record across the whole dataset, not just the current
page (primary keys qualify, display fields do not) - a selection
retained while paging is only as sound as this key. Same-page
duplicates raise; cross-page uniqueness is the caller's contract,
exactly as with LiveView stream ids.
Rows can be any enumerable of maps/structs; pair with
PetalComponents.DataTable.Engine.List for zero-setup in-memory
data, or run the state against your own query layer.
Summary
Functions
Applies one move_column gesture (field + dir) to the CURRENT
order. This is the whole contract: the event carries a delta, never a
computed destination, because a destination is a snapshot of the DOM
the user saw - two rapid moves would both start from it and the
second would silently undo the first. Applying the delta to the
server's own current order serializes gestures no matter how fast
they arrive
Functions
Attributes
id(:string) (required)rows(:list) - Defaults to[].state(PetalComponents.DataTable.State) (required)path(:string) - link mode: the base path sort and page changes patch to, with the state encoded as query params. Required unlesson_changeis set.Defaults to
nil.on_change(:string) - event mode: the event every table interaction pushes, with an op-shaped payload (opof "sort" | "page" | "clear_filters").Defaults to
nil.target(:any) - event mode: the phx-target (e.g. @myself). Defaults tonil.loading(:boolean) - render skeleton rows instead of data. Defaults tofalse.density(:string) - Defaults to"comfortable". Must be one of"comfortable", or"compact".striped(:boolean) - Defaults tofalse.sticky_header(:boolean) - pin the header row. Inside a data table this needsmax_heighttoo: the scroll region is what the header sticks to, and a wrapper that scrolls only sideways cannot pin anything.Defaults to
false.max_height(:string) - caps the table body's height (any CSS length), making it scroll under a pinned header. Defaults tonil.variant(:string) - Defaults to"basic". Must be one of"ghost", or"basic".of_label(:string) - the range summary's connective, localizable. Defaults to"of".page_label(:string) - cursor mode's page word, localizable. Defaults to"Page".no_results_text(:string) - Defaults to"No results".no_filtered_results_text(:string) - the empty message while filters are active. Defaults to"No results for these filters".clear_filters_label(:string) - Defaults to"Clear filters".results_label(:string) - the announced result-count noun, localizable. Defaults to"results".actions_label(:string) - the actions column's header, announced but not shown. Defaults to"Actions".searchable(:boolean) - render the quick-search input in the toolbar (drivesstate.search). Defaults tofalse.search_placeholder(:string) - Defaults to"Search…".search_debounce(:integer) - quick-search debounce in ms, both wiring modes. Defaults to300.page_size_options(:list) - when non-empty, render a rows-per-page select in the footer. Defaults to[].per_page_label(:string) - the rows-per-page label, localizable. Defaults to"Per page".reset_filters_label(:string) - Defaults to"Reset filters".apply_label(:string) - the filter editors' submit label, localizable. Defaults to"Apply".filter_options_placeholder(:string) - the select editor's option-filter placeholder (shown from 8 options up), localizable. Defaults to"Filter options…".selectable(:boolean) - render a leading checkbox column. Defaults tofalse.selected(:list) - the currently selected row ids (any terms; compared as strings). Defaults to[].row_label(:any) - 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.Defaults to
nil.row_id(:any) - 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.Defaults to
:id.on_ui(:string) - the event UI-state ops (selection) push, both wiring modes. Defaults toon_change; required alongsideselectablein link mode.Defaults to
nil.selected_label(:string) - the selection count's word, localizable. Defaults to"selected".clear_selection_label(:string) - Defaults to"Clear selection".select_row_label(:string) - the row checkbox's aria-label prefix, localizable. Defaults to"Select row".select_all_label(:string) - the header checkbox's aria-label. Defaults to"Select all rows".column_toggle(:boolean) - render a columns-visibility dropdown in the toolbar (rides theon_uievent). Defaults tofalse.hidden_columns(:list) - fields currently hidden (atoms or strings) - presentation state, never in URLs. Defaults to[].column_toggle_label(:string) - Defaults to"Columns".reorderable(:boolean) - render move up/down controls in the Columns menu (requirescolumn_toggle). Defaults tofalse.column_order(:list) - fields in display order (atoms or strings) - presentation state on theon_uievent, likehidden_columns, never in URLs. Fields not listed keep their declared order after the listed ones. Empty means the declared:colorder.Defaults to
[].move_up_label(:string) - reorder buttons, localizable. Defaults to"Move up".move_down_label(:string) - Defaults to"Move down".filter_op_labels(:map) - overrides for the operator display names, e.g. %{contains: "enthält"}. Defaults to%{}.class(:any) - Defaults tonil.
Slots
col(required) - Accepts attributes:field(:atom) (required)label(:string)sortable(:boolean)filterable(:string) - render a typed filter button + popover editor for this column. Must be one of"text","number","select", or"date".options(:list) - select filters: the pickable values, as strings or {label, value} tuples.align(:string) - Must be one of"left","center", or"right".class(:any)
action- trailing actions column,:letreceives the row.bulk_action- toolbar content while rows are selected;:letreceives the selected ids.toolbar- custom toolbar content rendered above the table.empty- custom empty state; a filters-aware default renders otherwise.
Applies one move_column gesture (field + dir) to the CURRENT
order. This is the whole contract: the event carries a delta, never a
computed destination, because a destination is a snapshot of the DOM
the user saw - two rapid moves would both start from it and the
second would silently undo the first. Applying the delta to the
server's own current order serializes gestures no matter how fast
they arrive:
def handle_event("table", %{"op" => "move_column", "field" => f, "dir" => dir}, socket) do
fields = [:name, :email, :status, :amount]
{:noreply, update(socket, :order, &DataTable.move_column(&1, fields, f, dir))}
endcurrent_order may be [] (meaning the declared order, supplied as
all_fields). Unknown fields and edge positions are no-ops, and
stale fields in a saved order are dropped - a ghost entry would make
a visible move button do nothing until the field "crossed" it.