Composable, URL-driven filtering for LiveView with Linear/Notion-style UI filters and PostgREST-compatible parameters for shareable filter states using PgRest
See demo/ for an interactive filter explorer built with Phoenix LiveView.
cd demo && mix setup && mix phx.server
# Visit http://localhost:4000- Elixir 1.15+
- Phoenix LiveView 1.0+
- DaisyUI (via daisy_ui_components)
Add live_filter to your dependencies in mix.exs:
def deps do
[
{:live_filter, "~> 0.1.0"}
]
endThen fetch dependencies:
mix deps.getLiveFilter requires JavaScript hooks for dropdown behavior. Add them to your LiveSocket:
import { hooks as liveFilterHooks } from "live_filter"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...liveFilterHooks }
})For esbuild, add the deps path to your NODE_PATH in config/config.exs:
config :esbuild,
version: "0.25.4",
my_app: [
args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js),
cd: Path.expand("../assets", __DIR__),
env: %{
"NODE_PATH" => Path.expand("../deps", __DIR__)
}
]defmodule MyAppWeb.TaskLive.Index do
use MyAppWeb, :live_view
defp filter_config do
[
LiveFilter.text(:title, label: "Search", always_on: true),
LiveFilter.select(:status, label: "Status", options: ~w(pending active done)),
LiveFilter.multi_select(:tags, label: "Tags", options: ~w(bug feature docs)),
LiveFilter.boolean(:urgent, label: "Urgent Only"),
LiveFilter.date_range(:due_date, label: "Due Date")
]
end
enddef handle_params(params, _uri, socket) do
{filters, remaining_params} = LiveFilter.from_params(params, filter_config())
socket =
socket
|> LiveFilter.init(filter_config(), filters)
|> assign(:remaining_params, remaining_params)
|> load_data()
{:noreply, socket}
end
def handle_info({:live_filter, :updated, params}, socket) do
all_params = Map.merge(socket.assigns.remaining_params, params)
{:noreply, push_patch(socket, to: ~p"/tasks?#{all_params}")}
endUse the built-in UI component:
<LiveFilter.bar filter={@live_filter} />Or build your own UI — the param/query layers work independently:
# Parse params and build queries without the bar component
{filters, _} = LiveFilter.from_params(params, filter_config())
query = LiveFilter.QueryBuilder.apply(Task, filters, schema: Task, allowed_fields: [...])defp load_data(socket) do
query =
Task
|> LiveFilter.QueryBuilder.apply(socket.assigns.live_filter.filters,
schema: Task,
allowed_fields: [:title, :status, :tags, :urgent, :due_date]
)
assign(socket, :tasks, Repo.all(query))
end| Type | Function | Default Operators |
|---|---|---|
| Text | LiveFilter.text/2 |
ilike, eq, neq, like |
| Number | LiveFilter.number/2 |
eq, neq, gt, gte, lt, lte |
| Select | LiveFilter.select/2 |
eq, neq |
| Multi-select | LiveFilter.multi_select/2 |
ov, cs |
| Date | LiveFilter.date/2 |
eq, gt, gte, lt, lte |
| Date Range | LiveFilter.date_range/2 |
gte_lte |
| DateTime | LiveFilter.datetime/2 |
eq, gt, gte, lt, lte |
| Boolean | LiveFilter.boolean/2 |
is |
| Radio Group | LiveFilter.radio_group/2 |
eq |
LiveFilter supports two display modes for filter chips:
| Mode | Description |
|---|---|
:basic |
Simple chips without operator selection (default) |
:command |
Full chips with inline operator dropdown (Linear/Notion style) |
Set the mode globally on the bar:
<LiveFilter.bar filter={@live_filter} mode={:command} />Or per-filter in the configuration:
LiveFilter.number(:estimated_hours, label: "Hours", mode: :command)LiveFilter.text(:field,
label: "Display Label", # Human-readable label
always_on: true, # Always visible (not removable)
operators: [:eq, :ilike], # Allowed operators
default_operator: :ilike, # Default when adding filter
placeholder: "Search...", # Input placeholder
custom_param: "search", # Custom URL param name
query_field: :other_field, # Query different DB column
mode: :command # Display mode for this filter
)
LiveFilter.select(:status,
options: ["pending", "active"], # Static options
options_fn: fn -> fetch_options() end # Dynamic options
)
LiveFilter.boolean(:active,
nullable: true, # Allow nil (Any) state
true_label: "Active", # Custom label for true
false_label: "Inactive", # Custom label for false
any_label: "All" # Custom label for nil
)MIT