Skip to main content
WireKit
Copy for LLM

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.

An empty filter bar — click Add filter

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.

Pre-populated active filters

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)".

Filter bar with a search box

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.

Filter bar with a result count
42 members match

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:

The label is what the reader picks in the popover; the op key is what reaches your server in the payload, and the two are not always the same word. Text's "is" emits equals, which is the one pairing that will silently drop a filter if you branch on the label.

Type Operators (label → op)
text contains → contains · is → equals · starts with → starts · ends with → ends
number = → eq · > → gt · < → lt · ≥ → gte · ≤ → lte
select is → is · is not → isnot
date on → on · before → before · after → after
bool is → 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).
{{-- 1. Bind the JSON bridge straight to a Livewire property --}}
<x-wirekit::filter-builder :fields="$fields" wire:model.live="filtersJson" />
// 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']}%"),
            'starts'   => $query->where($f['field'], 'like', "{$f['value']}%"),
            'ends'     => $query->where($f['field'], 'like', "%{$f['value']}"),
            'equals', 'is', 'on' => $query->where($f['field'], $f['value']),
            'isnot'    => $query->where($f['field'], '!=', $f['value']),
            'eq'       => $query->where($f['field'], '=', $f['value']),
            'gt', 'after'  => $query->where($f['field'], '>', $f['value']),
            'lt', 'before' => $query->where($f['field'], '<', $f['value']),
            'gte'      => $query->where($f['field'], '>=', $f['value']),
            'lte'      => $query->where($f['field'], '<=', $f['value']),
            // 4. No `default` on purpose. An unhandled op throws UnhandledMatchError, which
            //    is loud; `default => $query` silently drops the filter and the reader sees a
            //    full result set that looks like "no matches were excluded".
        };
    }, Order::query())->get();
}

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

{{-- 5. 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:

<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
searchDebounce int 300 Milliseconds to wait after the last keystroke before emitting search-change. Without it, a host that wires the event to a Livewire call makes one round trip per character and discards every answer but the last. 0 emits immediately
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.
  • Removing a chip moves focus to the filter that took its place — the new last chip when you removed the last one, and the add-filter trigger when you removed the only one. Clear-all hands focus to the trigger before the button hides itself. Neither gesture leaves you on the page body.
  • Both are announced in a polite live region the component renders itself: "Filter removed: Status is Open" and "All filters cleared". You do not wire this up, and it is separate from the status slot below.
  • The status slot is ALREADY inside a polite live region — the component wraps it — so a result count placed there is announced when it changes. Do not add your own aria-live around it: nesting one live region inside another makes the announcement unpredictable (browsers differ on which region wins), and this bullet used to ask for exactly that while saying in the same sentence that the region was already there.

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:

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

Further Reading

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store