Filters
The data table's typed filter grammar as a standalone - seventeen operators across text, number, date and select columns, composable anywhere.
A live filter bar, wired in event mode
This one is real: open Add filter, pick a field, choose an operator, apply,
and the table below re-runs. Click a chip to reopen its editor pre-filled, or the
x
to drop it. The bar and the table share a single State
struct, so filtering from a column header adds a chip to the bar and filtering from the bar
reorders the table - they are not two components kept in sync, they are one piece of state
rendered twice.
The standalone filter bar
No filters applied
Name
Category
Price
In stock
Added
<.filters id="products-filters" state={@table} on_change="filters">
<:field field={:name} label="Name" type="text" />
<:field field={:category} label="Category" type="select" options={@categories} />
<:field field={:price} label="Price" type="number_range" />
<:field field={:in_stock} label="In stock" type="boolean" />
<:field field={:added_on} label="Added" type="date_range" />
</.filters>
<.data_table id="products" rows={@rows} state={@table} on_change="filters">
<:col :let={p} field={:name} sortable>{p.name}</:col>
<:col :let={p} field={:category} filterable="select" options={@categories}>
{p.category}
</:col>
<:col :let={p} field={:price} sortable align="right">${p.price}</:col>
</.data_table>
The whole backend
Chip removal, apply and clear-all post the op-shaped payloads State.handle_op/3
already parses, so a LiveView wired for an event-mode data table gains a filter bar without a
single new handler clause. The fields
option is the allow-list: an op naming any other field is dropped, so a crafted payload cannot
filter by a column you never exposed. This is the exact code behind the demo above.
# Both components post the same event with the same op grammar, so one
# clause is the whole backend - and because they share one State, a
# filter added from the bar and a filter added from a column header are
# the same thing.
def handle_event("filters", params, socket) do
state = State.handle_op(socket.assigns.table, params, fields: @fields)
{rows, state} = Engine.List.run(all_products(), state)
{:noreply, assign(socket, rows: rows, table: state)}
end
Anatomy
The two previews below render a fixed state so the empty and filled shapes sit side by side. They are labelled static because their controls cannot answer a click - the demo above is the one to click, and the code panels here are the real markup.
Nothing filtered yet
With no active filters the bar is one trigger. Open it and you get the field list, pick a field and the same panel swaps to that field's operator and value editor - no round trip, no hook, and Escape backs out with focus returned to the trigger.
No filters applied
Name
Category
Price
In stock
Added
<.filters id="sx-filters-empty" state={%State{}} on_change="table">
<:field field={:name} label="Name" type="text" />
<:field
field={:category}
type="select"
options={[{"Hand tools", "hand"}, {"Power tools", "power"}, {"Finishing", "finishing"}]}
/>
<:field field={:price} label="Price" type="number_range" />
<:field field={:in_stock} label="In stock" type="boolean" />
<:field field={:added_on} label="Added" type="date_range" />
</.filters>
Active filters as chips
Every filter in the state renders as a chip: field, humanised operator, formatted value. between shows both bounds, in shows a truncated list, and a valueless op like is_empty shows no value at all. Click the chip body to reopen its editor pre-filled; the x removes it and moves focus to the next chip.
Active filters: Category is Power tools, Price between 50–150, Name is not empty
In stock
<.filters
id="sx-filters-chips"
state={
%State{
filters: [
%{field: :category, op: :eq, value: "power"},
%{field: :price, op: :between, value: ["50", "150"]},
%{field: :name, op: :is_not_empty, value: true}
]
}
}
on_change="table"
>
<:field field={:name} label="Name" type="text" />
<:field
field={:category}
type="select"
options={[{"Hand tools", "hand"}, {"Power tools", "power"}, {"Finishing", "finishing"}]}
/>
<:field field={:price} label="Price" type="number_range" />
<:field field={:in_stock} label="In stock" type="boolean" />
</.filters>
Properties
| Attribute | Type | Default | Description |
|---|---|---|---|
active_filters_label
|
string |
"Active filters"
|
the chip group's accessible name, localizable |
add_filter_label
|
string |
"Add filter"
|
the add trigger's label and its panel's accessible name, localizable |
all_fields_used_label
|
string |
"Every field is already filtered"
|
shown in the add panel when no field is left to add, localizable |
apply_label
|
string |
"Apply"
|
the value editor's submit label, localizable |
class
|
any |
nil
|
extra classes on the bar's root element |
clear_filters_label
|
string |
"Clear filters"
|
the clear-all affordance's label, localizable |
filter_op_labels
|
map |
%{}
|
overrides for operator display names, e.g. %{contains: "enthält"} - same attr name and shape as data_table |
filter_options_placeholder
|
string |
"Filter options…"
|
the multi editor's option-filter placeholder (shown from 8 options up), localizable |
id*
|
string |
DOM id; every panel and chip id is derived from it | |
no_filters_label
|
string |
"No filters applied"
|
announced by the status region when nothing is filtered, localizable |
on_change
|
string |
nil
|
event mode: the event filter edits push, with the same op-shaped payloads `State.handle_op/3` already accepts (`filter` / `clear_filters`). |
path
|
string |
nil
|
link mode: the base path filter changes patch to, with the state encoded via `State.to_params/1`. Required unless `on_change` is set. |
remove_filter_label
|
string |
"Remove filter"
|
prefix for a chip's remove button accessible name, localizable |
state*
|
{:struct, PetalComponents.DataTable.State} |
the state whose `filters` this bar renders and edits | |
target
|
any |
nil
|
event mode: the phx-target (e.g. @myself) |
:field
slot
|
slot |
the filterable field registry, one entry per field |