Components Filters

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

Filters

The data table's typed filter grammar as a standalone - seventeen operators across text, number, date and select columns, composable anywhere.
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

heex
<.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

heex
<.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>
One State, bar and table

The composition proof. Both components read and write the same State struct, so filtering from the bar updates the table and filtering from a column header adds a chip. This example runs the free in-memory engine at render time in link mode - the whole backend is handle_params plus State.from_params/2.

Active filters: Category is Hand tools

Clear filters
Reset filters
Category
Chisel hand $156
Forge hand $297
Ingot hand $138
Lathe hand $279
heex
<% state = %State{
filters: [%{field: :category, op: :eq, value: "hand"}],
page_size: 5
} %>
<% {rows, state} =
Engine.List.run(PetalComponents.Showcase.Filters.products(), state) %>
<div class="flex flex-col gap-4">
<.filters id="sx-filters-shared" state={state} path="#">
<: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>
<.data_table id="sx-filters-table" rows={rows} state={state} path="#">
<:col :let={p} field={:name} sortable>{p.name}</:col>
<:col :let={p} field={:category} filterable="text">{p.category}</:col>
<:col :let={p} field={:price} sortable align="right">${p.price}</:col>
</.data_table>
</div>
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