A schema-driven filter bar: the active filters as removable chips, plus an "Add filter" popover that walks field -> operator -> value.
It speaks PetalComponents.DataTable.State, the same struct <.data_table>
is driven by, so it drops in next to a table sharing one state - or stands
alone above any list, card grid or custom query UI. The operator vocabulary
is State.ops/0; this component only ever selects subsets of it, and every
mutation goes through State.put_filter/4 or State.handle_op/3.
Two wiring modes, inferred from which attr you pass (passing neither raises,
exactly like data_table/1):
link mode (
path) - every change is apatchURL built fromState.to_params/1.handle_paramsplusState.from_params/2is the whole backend, and the URL is the state store:def handle_params(params, _uri, socket) do state = State.from_params(params, fields: [:name, :category, :price]) {rows, state} = Engine.List.run(all_products(), state) {:noreply, assign(socket, rows: rows, table: state)} end <.filters id="f" state={@table} path={~p"/products"}> <:field field={:name} label="Name" type="text" /> <:field field={:category} type="select" options={["tools", "toys"]} /> <:field field={:price} label="Price" type="number_range" /> </.filters>event mode (
on_change) - chip removal, apply and clear-all push the op-shaped payloadsState.handle_op/3already parses, so a LiveView already wired for an event-modedata_tablegains a filter bar without a single new handler clause:def handle_event("table", params, socket) do state = State.handle_op(socket.assigns.table, params, fields: [:name, :category]) {rows, state} = Engine.List.run(all_products(), state) {:noreply, assign(socket, rows: rows, table: state)} end <.filters id="f" state={@table} on_change="table"> <:field field={:name} label="Name" type="text" /> <:field field={:category} type="select" options={["tools", "toys"]} /> </.filters>
Sharing one State with a data table
Point both at the same struct and the same event (or the same path) and they compose with no glue: filter from the bar and the table updates, filter from a column header and a chip appears.
<.filters id="products-filters" state={@table} on_change="table">
<:field field={:category} type="select" options={["tools", "toys"]} />
<:field field={:in_stock} label="In stock" type="boolean" />
</.filters>
<.data_table id="products" rows={@rows} state={@table} on_change="table">
<:col :let={p} field={:name} sortable>{p.name}</:col>
<:col :let={p} field={:category} filterable="select" options={["tools", "toys"]}>
{p.category}
</:col>
</.data_table>Field types
The :field slot is the registry. type picks the operator subset (always a
subset of State.ops/0) and the value editor:
| type | operators | editor |
|---|---|---|
text | contains, not_contains, eq, neq, starts_with, is_empty, is_not_empty | text input |
select | eq, neq, is_empty, is_not_empty | single select from options |
multi | in | checkbox list from options |
number_range | eq, neq, gt, gte, lt, lte, between, is_empty, is_not_empty | number input, two for between |
date_range | before, on, after, between, is_empty, is_not_empty | date input, two for between |
boolean | eq | yes/no select, no operator picker |
Valueless operators (is_empty, is_not_empty) render no value input, per
State.valueless_op?/1.
Summary
Functions
Renders the filter bar for state, with one :field entry per filterable
field. Pass path for link mode or on_change for event mode.
The operator subset offered per registry type, as %{type => [op]}.
Functions
Renders the filter bar for state, with one :field entry per filterable
field. Pass path for link mode or on_change for event mode.
Attributes
id(:string) (required) - DOM id; every panel and chip id is derived from it.state(PetalComponents.DataTable.State) (required) - the state whosefiltersthis bar renders and edits.path(:string) - link mode: the base path filter changes patch to, with the state encoded viaState.to_params/1. Required unlesson_changeis set.Defaults to
nil.on_change(:string) - event mode: the event filter edits push, with the same op-shaped payloadsState.handle_op/3already accepts (filter/clear_filters).Defaults to
nil.target(:any) - event mode: the phx-target (e.g. @myself). Defaults tonil.add_filter_label(:string) - the add trigger's label and its panel's accessible name, localizable. Defaults to"Add filter".clear_filters_label(:string) - the clear-all affordance's label, localizable. Defaults to"Clear filters".apply_label(:string) - the value editor's submit label, localizable. Defaults to"Apply".remove_filter_label(:string) - prefix for a chip's remove button accessible name, localizable. Defaults to"Remove filter".active_filters_label(:string) - the chip group's accessible name, localizable. Defaults to"Active filters".no_filters_label(:string) - announced by the status region when nothing is filtered, localizable. Defaults to"No filters applied".all_fields_used_label(:string) - shown in the add panel when no field is left to add, localizable. Defaults to"Every field is already filtered".filter_op_labels(:map) - overrides for operator display names, e.g. %{contains: "enthält"} - same attr name and shape as data_table. Defaults to%{}.filter_options_placeholder(:string) - the multi editor's option-filter placeholder (shown from 8 options up), localizable. Defaults to"Filter options…".class(:any) - extra classes on the bar's root element. Defaults tonil.
Slots
field(required) - the filterable field registry, one entry per field. Accepts attributes:field(:atom) (required) - the state field this entry filters.label(:string) - human name; defaults to the humanized field.type(:string) - picks the operator subset and the value editor; defaults to "text". Must be one of"text","select","multi","date_range","boolean", or"number_range".options(:list) - select/multi: the pickable values, as strings or {label, value} tuples - same shape as data_table's :col options.
The operator subset offered per registry type, as %{type => [op]}.
Exposed so an app (and this library's own test suite) can assert the
vocabulary against State.ops/0 rather than trusting a hand-copied list.