Skip to main content

README.md

# LiveFilter

[![Hex.pm](https://img.shields.io/hexpm/v/livefilter.svg)](https://hex.pm/packages/livefilter)
[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/livefilter)
[![License](https://img.shields.io/hexpm/l/livefilter.svg)](https://github.com/agoodway/livefilter/blob/main/LICENSE)

> Composable, URL-driven filtering for Phoenix LiveView with Linear/Notion-style UI filters and PostgREST-compatible parameters for shareable filter states using [PgRest](https://github.com/agoodway/pgrest).

## Demo App

See [`demo/`](demo/) for an interactive filter explorer built with Phoenix LiveView.

The repo ships a Hivemind wrapper that serves the demo at
[`localhost:4032`](http://localhost:4032):

```bash
bin/livefilter start
bin/livefilter stop
bin/livefilter console
```

Requires a local Postgres reachable at `localhost:5432` with a `demo_dev`
database (`mix ecto.setup` from `demo/` creates it). Or run the demo directly:

```bash
cd demo && mix setup && mix phx.server
```

## Prerequisites

- Elixir 1.15+
- Phoenix LiveView 1.0+
- DaisyUI (via daisy_ui_components)

## Installation

Add `livefilter` to your dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:livefilter, "~> 0.1.8"}
  ]
end
```

Then fetch dependencies:

```bash
mix deps.get
```

### JavaScript Hooks

LiveFilter requires JavaScript hooks for dropdown behavior. Add them to your LiveSocket:

```javascript
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`:

```elixir
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__)
    }
  ]
```

## Quick Start

### 1. Define Filter Configuration

```elixir
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
end
```

### 2. Initialize in LiveView

```elixir
def 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({:livefilter, :updated, params}, socket) do
  all_params = Map.merge(socket.assigns.remaining_params, params)
  {:noreply, push_patch(socket, to: ~p"/tasks?#{all_params}")}
end
```

### 3. Render the Filter Bar (Optional)

Use the built-in UI component:

```heex
<LiveFilter.bar filter={@livefilter} />
```

Or build your own UI — the param/query layers work independently:

```elixir
# 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: [...])
```

### 4. Apply Filters to Queries

```elixir
defp load_data(socket) do
  query =
    Task
    |> LiveFilter.QueryBuilder.apply(socket.assigns.livefilter.filters,
      schema: Task,
      allowed_fields: [:title, :status, :tags, :urgent, :due_date]
    )

  assign(socket, :tasks, Repo.all(query))
end
```

## Filter Types

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

### Select Filter with Multi-Value Operators

Select filters support additional operators for multi-value selection. Add `:in` and `:not_in` to enable "is any of" and "is none of" filtering:

```elixir
LiveFilter.select(:status,
  label: "Status",
  options: ~w(draft pending active shipped),
  operators: [:eq, :neq, :in, :not_in],  # Enable multi-value operators
  mode: :command                          # Command mode shows operator dropdown
)
```

| Operator  | Label        | Value Mode | URL Format              | Description                  |
|-----------|--------------|------------|-------------------------|------------------------------|
| `:eq`     | "is"         | single     | `status=eq.active`      | Exact match                  |
| `:neq`    | "is not"     | single     | `status=neq.draft`      | Not equal                    |
| `:in`     | "is any of"  | multi      | `status=in.(a,b,c)`     | Matches any selected value   |
| `:not_in` | "is none of" | multi      | `status=not_in.(a,b)`   | Excludes all selected values |

The UI automatically switches between single-select and multi-select based on the active operator. When switching between single/multi operators, the value is cleared to prevent type mismatches.

## Display Modes

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:

```heex
<LiveFilter.bar filter={@livefilter} mode={:command} />
```

Or per-filter in the configuration:

```elixir
LiveFilter.number(:estimated_hours, label: "Hours", mode: :command)
```

## Theming

LiveFilter provides preset themes that control the styling of filter chips. Set the theme on the bar:

```heex
<LiveFilter.bar filter={@livefilter} theme={:neutral} variant={:neutral} />
```

### Available Themes

| Theme      | Description                                                    |
|------------|----------------------------------------------------------------|
| `:default` | DaisyUI btn-based styling with outline variant                 |
| `:minimal` | Lighter padding and simpler styling                            |
| `:bordered`| Primary color accent with borders                              |
| `:neutral` | Theme-aware utilities without DaisyUI btn classes              |

### Theme + Variant Combinations

The `theme` controls element styling (chip, badge, field, etc.) while `variant` controls the DaisyUI btn variant class:

| Variant    | Class Applied | Notes                                      |
|------------|---------------|--------------------------------------------|
| `:outline` | `btn-outline` | Can have dark active/focus states          |
| `:ghost`   | `btn-ghost`   | Transparent background                     |
| `:soft`    | `btn-soft`    | Subtle background                          |
| `:neutral` | (none)        | No btn variant, uses theme utilities only  |

### The `:neutral` Theme

The `:neutral` theme avoids DaisyUI's `btn` component classes entirely, using theme-aware Tailwind utilities instead. This prevents the dark active/focus states that `btn-outline` can produce on light themes:

```heex
<LiveFilter.bar filter={@livefilter} theme={:neutral} variant={:neutral} />
```

This theme uses:
- `bg-base-100`, `text-base-content`, `border-base-300` for automatic light/dark theme support
- `badge-soft` with `rounded` for squared badge styling
- No CSS overrides needed in your host app

### Tailwind Content Scanning

For Tailwind to generate LiveFilter's theme classes, add the library's templates to your `tailwind.config.js`:

```javascript
module.exports = {
  content: [
    // ... existing paths ...
    "../deps/livefilter/**/*.*ex",
  ],
}
```

## Filter Options

```elixir
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
)
```

## Pagination

LiveFilter includes a pagination component with PostgREST-compatible `limit`/`offset` URL params.

```elixir
# In handle_params
{filters, remaining} = LiveFilter.from_params(params, filter_config())
{pagination, remaining} = LiveFilter.pagination_from_params(remaining, default_limit: 25)

# Apply to query
query
|> LiveFilter.QueryBuilder.apply(filters, schema: Task)
|> LiveFilter.QueryBuilder.apply_pagination(pagination)

# Get total count for pagination UI
total = LiveFilter.QueryBuilder.count(base_query, Repo)
pagination = LiveFilter.Pagination.with_total(pagination, total)
```

Render the paginator:

```heex
<LiveFilter.paginator pagination={@pagination} />
```

### Paginator Options

| Option       | Default | Description                         |
|--------------|---------|-------------------------------------|
| `max_pages`  | 5       | Max page buttons in stepper         |
| `class`      | ""      | Additional CSS classes              |

```heex
<LiveFilter.paginator pagination={@pagination} max_pages={7} />
```

Handle page changes:

```elixir
def handle_info({:livefilter, :page_changed, pagination_params}, socket) do
  all_params = Map.merge(filter_params, pagination_params)
  {:noreply, push_patch(socket, to: ~p"/tasks?#{all_params}")}
end
```

## License

MIT