---
title: Filter Builder
description: Active-filter chip bar with a typed add/edit popover (field/operator/value)
visibility: guest
draft: false
---

# Filter Builder

A compact filtering UI for any list or table: active filters render as removable
chips, and an **Add filter** popover walks the user through picking a field, an
operator valid for that field's type, and a typed value. On every change it
emits a normalized `[{field, op, value}]` array — ready to drive a server query
or a client-side filter.

It composes form inputs behind a small Alpine state machine; there is no new
styling vocabulary to learn. Each field declares its `type`, and the popover
offers only the operators and value editor that make sense for it (no
`contains` on a number, no free-text on a boolean).

## Basic Usage

Pass an array of field definitions. Each is `{key, label, type}` with optional
`options` (for `select`) and custom `operators`.

:::preview{title="An empty filter bar — click Add filter"}
<x-wirekit::filter-builder :fields="[
    ['key' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => [
        ['value' => 'open', 'label' => 'Open'],
        ['value' => 'closed', 'label' => 'Closed'],
    ]],
    ['key' => 'title', 'label' => 'Title', 'type' => 'text'],
    ['key' => 'amount', 'label' => 'Amount', 'type' => 'number'],
    ['key' => 'due', 'label' => 'Due date', 'type' => 'date'],
]" />
:::

## With Active Filters

Seed the bar with existing filters via `:value`. Each filter is a chip — click
it to edit, or use the × to remove.

:::preview{title="Pre-populated active filters"}
<x-wirekit::filter-builder
    :fields="[
        ['key' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => [
            ['value' => 'open', 'label' => 'Open'],
            ['value' => 'closed', 'label' => 'Closed'],
        ]],
        ['key' => 'priority', 'label' => 'Priority', 'type' => 'select', 'options' => [
            ['value' => 'low', 'label' => 'Low'],
            ['value' => 'high', 'label' => 'High'],
        ]],
        ['key' => 'amount', 'label' => 'Amount', 'type' => 'number'],
    ]"
    :value="[
        ['field' => 'status', 'op' => 'is', 'value' => 'open'],
        ['field' => 'amount', 'op' => 'gt', 'value' => 1000],
    ]"
/>
:::

## With Search

Set `searchable` to add a free-text box alongside the chips — handy when the
list also supports keyword search. The box dispatches a bubbling
`search-change` event with the current term (independent of the structured
filters), so you can bind it on its own: `x-on:search-change="$wire.set('q',
$event.detail.value)"`.

:::preview{title="Filter bar with a search box"}
<x-wirekit::filter-builder
    searchable
    search-placeholder="Search orders…"
    :fields="[
        ['key' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => [
            ['value' => 'paid', 'label' => 'Paid'],
            ['value' => 'refunded', 'label' => 'Refunded'],
        ]],
        ['key' => 'total', 'label' => 'Total', 'type' => 'number'],
    ]"
/>
:::

## With A Result Count

The `status` slot sits below the chips inside an `aria-live` region — perfect
for a result count that updates as filters change.

:::preview{title="Filter bar with a result count"}
<x-wirekit::filter-builder
    :fields="[
        ['key' => 'role', 'label' => 'Role', 'type' => 'select', 'options' => [
            ['value' => 'admin', 'label' => 'Admin'],
            ['value' => 'member', 'label' => 'Member'],
        ]],
        ['key' => 'active', 'label' => 'Active', 'type' => 'bool'],
    ]"
    :value="[['field' => 'role', 'op' => 'is', 'value' => 'member']]"
>
    <x-slot:status>42 members match</x-slot:status>
</x-wirekit::filter-builder>
:::

## Field Definitions

Each entry in `fields` describes one filterable column:

| Key | Required | Description |
| --- | --- | --- |
| `key` | yes | The field identifier emitted in the filter object |
| `label` | yes | The human-readable name shown in the chip + popover |
| `type` | yes | `text` · `number` · `select` · `date` · `bool` |
| `options` | for `select` | `[{value, label}]` choices for the value editor |
| `operators` | no | Override the default operator set: `[{op, label}]` |

### Operators By Type

The popover offers these operators automatically; override per field with
`operators`:

| Type | Operators |
| --- | --- |
| `text` | contains · is · starts with · ends with |
| `number` | = · > · < · ≥ · ≤ |
| `select` | is · is not |
| `date` | on · before · after |
| `bool` | is |

## The Emitted Value

On every add / edit / remove / clear, filter-builder emits the normalized array
two ways:

1. A bubbling **`filter-change`** event with `detail.filters`.
2. A JSON string written to a hidden input (so `wire:model` and plain forms
   both work).

```blade
{{-- 1. Bind the JSON bridge straight to a Livewire property --}}
<x-wirekit::filter-builder :fields="$fields" wire:model.live="filtersJson" />
```

```php
// 2. Decode the JSON the bridge writes, then apply it to your query
public string $filtersJson = '[]';

public function rows()
{
    $filters = json_decode($this->filtersJson, true) ?: [];

    return collect($filters)->reduce(function ($query, $f) {
        // 3. Map (field, op, value) onto your query builder
        return match ($f['op']) {
            'contains' => $query->where($f['field'], 'like', "%{$f['value']}%"),
            'gt'       => $query->where($f['field'], '>', $f['value']),
            'is'       => $query->where($f['field'], $f['value']),
            default    => $query,
        };
    }, Order::query())->get();
}
```

Prefer events? Listen for `filter-change` and set the property yourself:

```blade
{{-- 4. Event-driven alternative — receive the array directly --}}
<x-wirekit::filter-builder
    :fields="$fields"
    x-on:filter-change="$wire.set('filters', $event.detail.filters)"
/>
```

## Livewire Integration

`<x-wirekit::filter-builder>` keeps its active filters in internal state seeded
from the `value` prop, so when binding with Livewire pass the bound property as
`:value` **alongside** `wire:model` to seed the initial filter rows:

```blade
<x-wirekit::filter-builder wire:model.live="filters" :value="$filters" :fields="$fields" />
```

Without `:value` the builder starts with no filter rows until the user adds one;
`wire:model` keeps it in sync afterward. This is the framework-agnostic seeding
pattern WireKit's stateful controls share — it works in plain Blade forms too.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fields` | array | `[]` | Field definitions (see above) |
| `value` | array | `[]` | Initial active filters `[{field, op, value}]` |
| `name` | string\|null | `null` | Hidden-input name for plain-form submission |
| `searchable` | bool | `false` | Render a free-text search box in the chip bar |
| `searchPlaceholder` | string | `'Search…'` | Placeholder for the search box |
| `addLabel` | string | `'Add filter'` | Label for the add-filter trigger |
| `scope` | string\|null | `null` | Scoped personalization name |

## Accessibility

- Each chip is a button pair: the body opens the edit popover, the × removes the
  filter. Both are keyboard reachable and carry a descriptive `aria-label`
  ("Edit filter: Status is Open", "Remove filter: Status is Open").
- The add-filter trigger exposes `aria-haspopup="dialog"` and `aria-expanded`.
- The popover is a `role="dialog"` labeled by its heading. Opening it moves
  focus to the field selector; Escape, Cancel, and Apply return focus to the
  trigger. Clicking outside closes it without stealing focus back.
- Every popover control (field / operator / value) is a labeled form element.
- Wrap the `status` slot's result count in your own `aria-live` context (the
  slot is already inside a `polite` live region) so screen-reader users hear the
  count change.

## Keyboard Interaction

| Key | Action |
| --- | --- |
| `Tab` | Move between chips, the add-filter trigger, and clear-all |
| `Enter` / `Space` | Open the popover (on the trigger) or edit (on a chip) |
| `Enter` | Apply the draft filter (from the text value editor) |
| `Escape` | Close the popover, returning focus to the trigger |

## Design Tokens

| Element | Token |
| --- | --- |
| Chip background | `--color-wk-bg-muted` |
| Chip text | `--color-wk-text` |
| Add-filter dashed border | `--color-wk-border` |
| Popover surface | `--color-wk-bg-elevated` |
| Popover shadow | `--shadow-wk-lg` |
| Apply button | `--color-wk-accent` |
| Remove-hover / clear-hover | `--color-wk-danger-text` |
| Focus ring | `--color-wk-ring` |

## Customization

Override the labels and the search default without publishing views via
`config/wirekit.php`:

```php
'components' => [
    'filter-builder' => [
        'searchable' => true,
        'search-placeholder' => 'Search records…',
        'add-label' => 'Add condition',
    ],
],
```

## Further Reading

- [WAI-ARIA Authoring Practices — Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
- [Livewire — Data binding](https://livewire.laravel.com/docs/properties#data-binding)
