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:
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:
- 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']}%"),
'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"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.
- 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
politelive 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 thestatusslot below. - The
statusslot is ALREADY inside apolitelive region — the component wraps it — so a result count placed there is announced when it changes. Do not add your ownaria-livearound 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',
],
],