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.
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.
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)".
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.
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:
- A bubbling
filter-changeevent withdetail.filters. - A JSON string written to a hidden input (so
wire:modeland 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']}%"),
'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:
{{-- 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:
<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"andaria-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
statusslot's result count in your ownaria-livecontext (the slot is already inside apolitelive 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:
'components' => [
'filter-builder' => [
'searchable' => true,
'search-placeholder' => 'Search records…',
'add-label' => 'Add condition',
],
],