Filters
A faceted filter toolbar of popover facets with searchable multi-select option lists, count badges, and a clear-all action.
Preview
No results.
No results.
No results.
<div class="w-full max-w-2xl">
<x-ui.filters />
</div>
Installation
php artisan ui:add filters
Note
This component ships an Alpine behavior module at
resources/js/ui/filters.js. Import it once from your bundle so it registers on alpine:init:
import './filters.js';
Registry contract
php artisan ui:add filters
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/filters.blade.php -
resources/js/ui/filters.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Filters (`filters`)
A faceted filter toolbar of popover facets with searchable multi-select option lists, count badges, and a clear-all action.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add filters
```
## Usage
```blade
<div class="w-full max-w-2xl">
<x-ui.filters />
</div>
```
## Props
- `facets` (mixed|null, default `null`) — Array of facet definitions (key, label, options, selected); defaults to a status/role/priority demo set. Options may carry a count (matching records). A facet may opt in to operators: 'operators' (from is, is-not, includes, does-not-include, before, after) and 'operator' (the default, else the first). A facet may set 'type': 'options' (the default, a searchable checkbox list), 'text' (a free-text condition: 'placeholder'; 'selected' holds at most one string) or 'search' (an async picker built on combobox in remote mode: 'endpoint' or 'wireSearch' / 'wire-search', 'searchParam', 'options' as the first list, 'multiple' (default true), 'labels' (value => label for chosen values), 'placeholder', 'emptyText', 'debounce'). An unknown type is an options facet.
- `name` (mixed|null, default `null`) — When set, renders hidden inputs per selected value under name[key][] for plain form submission. A facet with operators submits name[key][operator] and name[key][values][] instead. A text facet submits one scalar name[key] (name[key][value] with operators), and nothing while empty; a search facet submits name[key][] like an options facet (the combobox inside has no name).
- `variant` (default|flush, default `default`) — default: the bar is a bordered, padded box. flush: no box, so the bar sits flush in an app header or toolbar; on a phone the facets stay on one line (the triggers shrink and truncate their labels, the filter glyph hides and "Clear filters" keeps only its icon, with the text for screen readers).
## Use when
- Use when the option set is long enough that search, suggestion, or filtering speeds selection.
- Letting users narrow a table or list by several independent facets, such as status, role, or priority.
- Showing active filter counts and a single clear-all action above filtered results.
- A facet whose values come from a server search (an account, a site, a subject) or a free-text condition (title contains); use a search or text facet instead of setting it from elsewhere.
## Avoid when
- Do not use advanced selection UI when a simpler visible control would be clearer.
- As filter complexity rises, use a dedicated panel, sheet, or page rather than only chips or inline toggles.
- Filtering by a single field; use a plain select or a segmented-nav toggle.
- A single free-text search over the whole list with no facets; use a search input instead (a text facet is for one field's condition).
## Anti-patterns
- Using advanced search for a tiny list
## Rules
- Use the `<brok:filters>` tag (or `<x-ui.filters>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/filters
- Registry JSON (files, props, contract): https://brokui.dev/r/open/filters.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- Facet types: Account is a `search` facet, a combobox that fetches its
options from an endpoint as you type (wire-search="method" does the same
inside a Livewire component); its chosen values submit as filters[account][]
and the trigger shows their labels. Title is a `text` facet: type, press
Enter (or Apply), and it submits as one value, filters[title]. The log
shows each change event. --}}
<form
x-data="{ last: null }"
x-on:change="$event.detail && $event.detail.key !== undefined && (last = JSON.stringify($event.detail.value !== undefined
? { key: $event.detail.key, value: $event.detail.value, values: $event.detail.values }
: { key: $event.detail.key, values: $event.detail.values, labels: $event.detail.labels }))"
x-on:submit.prevent
class="flex w-full max-w-2xl flex-col gap-3"
>
<x-ui.filters
name="filters"
:facets="[
[
'key' => 'account',
'label' => __('Account'),
'type' => 'search',
'endpoint' => route('demo.combobox.accounts', [], false),
'placeholder' => __('Search accounts'),
'emptyText' => __('No accounts match.'),
'selected' => ['juniper'],
'labels' => ['juniper' => 'Juniper Legal'],
],
[
'key' => 'title',
'label' => __('Title'),
'type' => 'text',
'placeholder' => __('Title contains…'),
],
[
'key' => 'status',
'label' => __('Status'),
'options' => [
['value' => 'open', 'label' => __('Open')],
['value' => 'closed', 'label' => __('Closed')],
],
'selected' => ['open'],
],
]"
/>
<p class="text-xs text-muted-foreground">
{{ __('Last change:') }}
<span data-slot="preview-event-log" class="font-mono text-foreground" x-text="last ?? '—'"></span>
</p>
</form>
<div class="flex w-full max-w-2xl min-w-0 items-center gap-4 border-b border-border pb-2">
<span class="shrink-0 text-sm font-semibold text-foreground">{{ __('Inbox') }}</span>
<x-ui.filters variant="flush" />
</div>
Operators
{{-- Facets with operators and match counts. Status offers is / is not,
Labels includes / does not include; the log shows each change event. --}}
<form
x-data="{ last: null }"
x-on:change="$event.detail && $event.detail.key !== undefined && (last = JSON.stringify({ key: $event.detail.key, operator: $event.detail.operator, values: $event.detail.values }))"
x-on:submit.prevent
class="flex w-full max-w-2xl flex-col gap-3"
>
<x-ui.filters
name="filters"
:facets="[
[
'key' => 'status',
'label' => __('Status'),
'operators' => ['is', 'is-not'],
'options' => [
['value' => 'todo', 'label' => __('Todo'), 'count' => 12],
['value' => 'in-progress', 'label' => __('In progress'), 'count' => 5],
['value' => 'done', 'label' => __('Done'), 'count' => 148],
],
'selected' => ['done'],
'operator' => 'is-not',
],
[
'key' => 'labels',
'label' => __('Labels'),
'operators' => ['includes', 'does-not-include'],
'options' => [
['value' => 'bug', 'label' => __('Bug'), 'count' => 9],
['value' => 'design', 'label' => __('Design'), 'count' => 3],
['value' => 'billing', 'label' => __('Billing'), 'count' => 0],
],
'selected' => [],
],
]"
/>
<p class="text-xs text-muted-foreground">
{{ __('Last change:') }}
<span data-slot="preview-event-log" class="font-mono text-foreground" x-text="last ?? '—'"></span>
</p>
</form>
<div class="w-full max-w-2xl">
<x-ui.filters
:facets="[
[
'key' => 'status',
'label' => __('Status'),
'options' => [
['value' => 'active', 'label' => __('Active')],
['value' => 'pending', 'label' => __('Pending')],
['value' => 'archived', 'label' => __('Archived')],
],
'selected' => ['active', 'pending'],
],
[
'key' => 'role',
'label' => __('Role'),
'options' => [
['value' => 'owner', 'label' => __('Owner')],
['value' => 'admin', 'label' => __('Admin')],
['value' => 'member', 'label' => __('Member')],
['value' => 'guest', 'label' => __('Guest')],
],
'selected' => ['admin'],
],
[
'key' => 'priority',
'label' => __('Priority'),
'options' => [
['value' => 'high', 'label' => __('High')],
['value' => 'medium', 'label' => __('Medium')],
['value' => 'low', 'label' => __('Low')],
],
'selected' => [],
],
]"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| facets | mixed | null | null | Array of facet definitions (key, label, options, selected); defaults to a status/role/priority demo set. Options may carry a count (matching records). A facet may opt in to operators: 'operators' (from is, is-not, includes, does-not-include, before, after) and 'operator' (the default, else the first). A facet may set 'type': 'options' (the default, a searchable checkbox list), 'text' (a free-text condition: 'placeholder'; 'selected' holds at most one string) or 'search' (an async picker built on combobox in remote mode: 'endpoint' or 'wireSearch' / 'wire-search', 'searchParam', 'options' as the first list, 'multiple' (default true), 'labels' (value => label for chosen values), 'placeholder', 'emptyText', 'debounce'). An unknown type is an options facet. |
| name | mixed | null | null | When set, renders hidden inputs per selected value under name[key][] for plain form submission. A facet with operators submits name[key][operator] and name[key][values][] instead. A text facet submits one scalar name[key] (name[key][value] with operators), and nothing while empty; a search facet submits name[key][] like an options facet (the combobox inside has no name). |
| variant | default | flush | default | default: the bar is a bordered, padded box. flush: no box, so the bar sits flush in an app header or toolbar; on a phone the facets stay on one line (the triggers shrink and truncate their labels, the filter glyph hides and "Clear filters" keeps only its icon, with the text for screen readers). |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Each facet opens a searchable, multi-select popover; selecting an option updates the trigger's count badge and preview text.
- The clear-filters button only appears once at least one facet has an active selection.
- With a name prop, selected values are mirrored into hidden inputs so the toolbar submits in a plain form without JavaScript on the server side.
- A facet with operators shows an operator select above the search in its popover and names the operator in the trigger ("is not"); changing it dispatches change with the facet's current values.
- Every change event detail carries operator: the facet's operator, or null for a facet without operators. Default output is unchanged when no facet sets operators or counts.
- An option with count shows it right-aligned in tabular numbers, so the columns of counts line up.
- The trigger preview names the chosen values by their labels for every facet type (option labels, search-facet labels, the text itself), and more than two show the translated ":count selected".
- Text facet: the popover is a dialog with a labelled text input, Clear and Apply; Enter or Apply keeps the trimmed text as a one-element list (empty clears), closes the popover and returns focus to the trigger. The change event detail adds value (the text) beside values.
- Search facet: the popover is a dialog holding a remote combobox (its list sits in the popover's flow, so it never covers Clear); opening the facet focuses the search field, which asks for the first list. A pick updates the count, the hidden inputs and the trigger labels, and emits the usual change event (key, values, operator, selected) plus labels (value => label of the facet's values). The combobox's own change event stops at the facet.
- Opening any facet moves focus into its first text field (the option search, the text input or the combobox); Escape closes the combobox list first, then the facet, and focus returns to the facet trigger.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Find and select within a long or uncertain option set.
Use when
- Use when the option set is long enough that search, suggestion, or filtering speeds selection.
- Letting users narrow a table or list by several independent facets, such as status, role, or priority.
- Showing active filter counts and a single clear-all action above filtered results.
- A facet whose values come from a server search (an account, a site, a subject) or a free-text condition (title contains); use a search or text facet instead of setting it from elsewhere.
Avoid when
- Do not use advanced selection UI when a simpler visible control would be clearer.
- As filter complexity rises, use a dedicated panel, sheet, or page rather than only chips or inline toggles.
- Filtering by a single field; use a plain select or a segmented-nav toggle.
- A single free-text search over the whole list with no facets; use a search input instead (a text facet is for one field's condition).
Use instead
- Visible choices for small sets
- Dedicated filter panel for complex refinement
Anti-patterns
- Using advanced search for a tiny list
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Escape
- Focus
managed
- Each options-facet trigger sets aria-expanded and aria-haspopup="listbox"; the open popover uses role="listbox" with aria-multiselectable="true". Text and search facets use role="dialog" with the facet label as aria-label, and their triggers aria-haspopup="dialog".
- Escape closes an open facet popover and returns focus to its trigger; clicking outside also closes it.
- The operator select is labelled "Condition for <facet>"; an option count is read as "12 matches" after the option name while the bare number is hidden from assistive technology.
- The text input is labelled by the facet label; the search facet's combobox is labelled "Search <facet>" and keeps the ARIA 1.2 combobox pattern (aria-activedescendant, aria-busy, a polite status).
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="filters-{{ $record->id }}">
<div class="w-full max-w-2xl">
<x-ui.filters />
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:filters
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Filters: a faceted filter bar. Each facet is ['key', 'label', 'options',
'selected']; options are ['value', 'label'] with an optional 'count' (the
number of records that match, shown right-aligned).
Operators (opt-in per facet): 'operators' => ['is', 'is-not'] lists the
conditions the facet offers, from is, is-not, includes, does-not-include,
before and after; 'operator' picks the default (else the first). The
popover then shows an operator select, the trigger names the operator, the
`change` event carries `operator`, and with `name` the facet submits as
name[key][operator] plus name[key][values][] instead of name[key][].
Facet types (opt-in per facet, 'type'): 'options' (the default, above);
'text', a free-text condition: the popover holds a labelled text input
('placeholder') with Apply and Clear, Enter applies, the value is kept as
a one-element list and submits as name[key] (name[key][value] beside
name[key][operator] when the facet has operators); 'search', an async
picker: the popover holds <x-ui.combobox> in remote mode ('endpoint' or
'wireSearch' / 'wire-search', 'searchParam', 'options' as the first list,
'multiple' (default true), 'labels' for chosen values, 'placeholder',
'emptyText', 'debounce'). Text and search popovers are dialogs.
Variant: `default` draws the bar as a bordered, padded box. `flush` drops
the box so the bar sits flush in an app header or toolbar, and on a phone
keeps the facets on one line: the triggers shrink and truncate their
labels, the filter glyph and the "Clear filters" text (kept for screen
readers) give way first.
--}}
@props([
'facets' => null,
'name' => null,
'variant' => 'default',
])
@php
$facets = $facets ?? [
[
'key' => 'status',
'label' => __('Status'),
'options' => [
['value' => 'active', 'label' => __('Active')],
['value' => 'pending', 'label' => __('Pending')],
['value' => 'archived', 'label' => __('Archived')],
],
'selected' => [],
],
[
'key' => 'role',
'label' => __('Role'),
'options' => [
['value' => 'owner', 'label' => __('Owner')],
['value' => 'admin', 'label' => __('Admin')],
['value' => 'member', 'label' => __('Member')],
['value' => 'guest', 'label' => __('Guest')],
],
'selected' => [],
],
[
'key' => 'priority',
'label' => __('Priority'),
'options' => [
['value' => 'high', 'label' => __('High')],
['value' => 'medium', 'label' => __('Medium')],
['value' => 'low', 'label' => __('Low')],
],
'selected' => [],
],
];
$operatorLabels = [
'is' => __('is'),
'is-not' => __('is not'),
'includes' => __('includes'),
'does-not-include' => __('does not include'),
'before' => __('before'),
'after' => __('after'),
];
// Normalise selected map (and, for opt-in facets, the operator map) for
// the Alpine store. Unknown operator names are dropped.
$selectedMap = [];
$operatorMap = [];
$typeMap = [];
// value => label per facet, so the trigger names the chosen values.
$labelMap = [];
foreach ($facets as $index => $facet) {
$type = in_array($facet['type'] ?? 'options', ['options', 'text', 'search'], true) ? ($facet['type'] ?? 'options') : 'options';
$facets[$index]['type'] = $type;
$facets[$index]['options'] = (array) ($facet['options'] ?? []);
$selected = array_values((array) ($facet['selected'] ?? []));
$labels = [];
if ($type === 'options') {
foreach ($facets[$index]['options'] as $option) {
$labels[(string) $option['value']] = (string) $option['label'];
}
} else {
$typeMap[$facet['key']] = $type;
$selected = array_values(array_filter(array_map('strval', $selected), 'filled'));
}
if ($type === 'text') {
$selected = array_slice($selected, 0, 1);
}
if ($type === 'search') {
$facets[$index]['multiple'] = (bool) ($facet['multiple'] ?? true);
if (! $facets[$index]['multiple']) {
$selected = array_slice($selected, 0, 1);
}
foreach ($facets[$index]['options'] as $optionKey => $option) {
if (is_array($option) && isset($option['value'])) {
$labels[(string) $option['value']] = (string) ($option['label'] ?? $option['value']);
} elseif (is_scalar($option)) {
$labels[(string) $optionKey] = (string) $option;
}
}
foreach ((array) ($facet['labels'] ?? []) as $labelValue => $labelText) {
$labels[(string) $labelValue] = (string) $labelText;
}
$facets[$index]['labels'] = $labels;
}
$selectedMap[$facet['key']] = $selected;
$labelMap[$facet['key']] = $labels;
$allowed = array_values(array_filter(
array_map('strval', (array) ($facet['operators'] ?? [])),
static fn (string $operator): bool => array_key_exists($operator, $operatorLabels),
));
$facets[$index]['operators'] = $allowed;
if ($allowed !== []) {
$default = (string) ($facet['operator'] ?? '');
$operatorMap[$facet['key']] = in_array($default, $allowed, true) ? $default : $allowed[0];
}
}
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$isFlush = $variant === 'flush';
$variant = $isFlush ? 'flush' : 'default';
$rootClasses = $isFlush
? 'flex min-w-0 max-w-full flex-nowrap items-center gap-2 sm:flex-wrap'
: 'flex flex-wrap items-center gap-2 rounded-md border border-border bg-background p-2';
$config = [
'selected' => $selectedMap,
'labels' => $labelMap,
'texts' => ['many' => __(':count selected')],
];
if ($typeMap !== []) {
$config['types'] = $typeMap;
}
if ($operatorMap !== []) {
$config['operators'] = $operatorMap;
$config['operatorLabels'] = $operatorLabels;
}
@endphp
<div
data-slot="filters"
data-variant="{{ $variant }}"
x-data="uiFilters(@js($config))"
{{ $attributes->merge(['class' => $rootClasses]) }}
>
@foreach ($facets as $facet)
@php
$key = $facet['key'];
$type = $facet['type'];
@endphp
<div @class(['relative', 'min-w-0' => $isFlush]) @keydown.escape="if (open === @js($key)) { $event.stopPropagation(); close(true) }" @click.outside="open === @js($key) && close()">
<button
type="button"
data-slot="filters-trigger"
data-facet="{{ $key }}"
@click="openFacet(@js($key))"
:aria-expanded="(open === @js($key)).toString()"
aria-haspopup="{{ $type === 'options' ? 'listbox' : 'dialog' }}"
@class([
'inline-flex h-9 items-center gap-2 rounded-md border border-dashed border-input bg-background text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring',
'px-3' => ! $isFlush,
'min-w-0 max-w-full px-2 sm:px-3' => $isFlush,
])
>
<svg @class(['size-4 shrink-0 text-muted-foreground', 'max-sm:hidden' => $isFlush]) viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 4.5h18M6 12h12M10 19.5h4" />
</svg>
<span @if ($isFlush) class="min-w-0 truncate" @endif>{{ $facet['label'] }}</span>
<template x-if="count(@js($key)) > 0">
<span class="inline-flex shrink-0 items-center gap-2">
<span @class(['h-4 w-px bg-border', 'max-sm:hidden' => $isFlush]) aria-hidden="true"></span>
<x-ui.badge variant="secondary" size="sm" x-text="count({{ Js::from($key) }})" :aria-label="__('selected')"></x-ui.badge>
@if ($facet['operators'] !== [])
<span data-slot="filters-operator-label" class="hidden text-xs text-muted-foreground sm:inline" x-text="operatorLabel(@js($key))"></span>
@endif
<span class="hidden text-xs text-muted-foreground sm:inline" x-text="preview(@js($key))"></span>
</span>
</template>
</button>
<div
x-show="open === @js($key)"
x-cloak
x-transition.opacity
data-slot="filters-popover"
@if ($type === 'options')
role="listbox"
aria-multiselectable="true"
@else
data-type="{{ $type }}"
role="dialog"
@endif
aria-label="{{ $facet['label'] }}"
class="absolute start-0 z-dropdown mt-2 w-64 max-w-none overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md"
>
<div class="{{ $type === 'options' ? 'flex flex-col gap-2 border-b border-border p-2' : 'flex flex-col gap-2 p-2' }}">
@if ($facet['operators'] !== [])
<label class="sr-only" for="filters-operator-{{ $key }}">{{ __('Condition for :label', ['label' => $facet['label']]) }}</label>
<select
id="filters-operator-{{ $key }}"
data-slot="filters-operator"
:value="operators[@js($key)]"
@change="setOperator(@js($key), $event.target.value)"
class="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
>
@foreach ($facet['operators'] as $operator)
<option value="{{ $operator }}" @selected(($operatorMap[$key] ?? null) === $operator)>{{ $operatorLabels[$operator] }}</option>
@endforeach
</select>
@endif
@if ($type === 'text')
<label class="sr-only" for="filters-text-{{ $key }}">{{ $facet['label'] }}</label>
<input
id="filters-text-{{ $key }}"
type="text"
data-slot="filters-text"
x-model="drafts[@js($key)]"
@keydown.enter.prevent="applyText(@js($key))"
placeholder="{{ $facet['placeholder'] ?? __('Type a value…') }}"
autocomplete="off"
class="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
/>
@elseif ($type === 'search')
{{-- The combobox's change event stops on this wrapper, in the
filters scope (the two components share state names),
and comes out of the filters root as the facet's change.
Its list sits in the popover's flow, so it never covers Clear. --}}
<div
data-slot="filters-search"
data-facet="{{ $key }}"
x-on:change.stop="pickSearch(@js($key), $event.detail)"
class="[&_[data-slot=combobox-list]]:static [&_[data-slot=combobox-list]]:border-0 [&_[data-slot=combobox-list]]:px-0 [&_[data-slot=combobox-list]]:pb-0 [&_[data-slot=combobox-list]]:shadow-none"
>
{{-- name null: the combobox must not take the filters' name through
@aware and submit a second value; the hidden inputs below submit. --}}
<x-ui.combobox
:name="null"
:endpoint="$facet['endpoint'] ?? null"
:wire-search="$facet['wireSearch'] ?? ($facet['wire-search'] ?? null)"
:search-param="$facet['searchParam'] ?? 'q'"
:options="$facet['options'] !== [] ? $facet['options'] : null"
:labels="$facet['labels']"
:multiple="$facet['multiple']"
:value="$facet['multiple'] ? $selectedMap[$key] : ($selectedMap[$key][0] ?? '')"
:placeholder="$facet['placeholder'] ?? __('Search…')"
:empty-text="$facet['emptyText'] ?? null"
:debounce="$facet['debounce'] ?? 200"
input-id="filters-search-{{ $key }}"
:aria-label="__('Search :label', ['label' => $facet['label']])"
/>
</div>
@else
<label class="sr-only" for="filters-search-{{ $key }}">{{ __('Search :label', ['label' => $facet['label']]) }}</label>
<input
id="filters-search-{{ $key }}"
type="text"
x-model="query"
placeholder="{{ __('Search…') }}"
autocomplete="off"
class="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
/>
@endif
</div>
@if ($type === 'options')
<div class="max-h-60 overflow-y-auto p-1">
@foreach ($facet['options'] as $option)
@php $value = $option['value']; @endphp
<label
x-show="matches(@js($option['label']))"
class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-sm text-foreground hover:bg-muted"
>
<input
type="checkbox"
class="size-4 rounded border-input text-primary focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
:checked="isChecked(@js($key), @js($value))"
@change="toggleOption(@js($key), @js($value))"
/>
<span class="min-w-0 flex-1 truncate">{{ $option['label'] }}</span>
@if (isset($option['count']))
<span data-slot="filters-option-count" class="shrink-0 text-xs tabular-nums text-muted-foreground" aria-hidden="true">{{ (int) $option['count'] }}</span>
<span class="sr-only">{{ trans_choice(':count match|:count matches', (int) $option['count'], ['count' => (int) $option['count']]) }}</span>
@endif
</label>
@endforeach
<p x-show="!hasVisible(@js(array_column($facet['options'], 'label')))" class="px-2 py-2 text-sm text-muted-foreground">
{{ __('No results.') }}
</p>
</div>
@endif
<div class="{{ $type === 'text' ? 'flex items-center gap-1 border-t border-border p-1' : 'border-t border-border p-1' }}">
<button
type="button"
@click="clearFacet(@js($key))"
class="{{ $type === 'text' ? 'flex-1 ' : 'w-full ' }}rounded-sm px-2 py-2 text-start text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
{{ __('Clear') }}
</button>
@if ($type === 'text')
<button
type="button"
data-slot="filters-apply"
@click="applyText(@js($key))"
class="shrink-0 rounded-sm bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
>
{{ __('Apply') }}
</button>
@endif
</div>
</div>
@if ($name && $type === 'text')
{{-- A text facet submits one scalar value, and nothing while empty. --}}
@if ($facet['operators'] !== [])
<input type="hidden" name="{{ $name }}[{{ $key }}][operator]" :value="operators[@js($key)]" value="{{ $operatorMap[$key] }}" />
@endif
<input
type="hidden"
name="{{ $name }}[{{ $key }}]{{ $facet['operators'] !== [] ? '[value]' : '' }}"
:value="(selected[@js($key)] || [])[0] ?? ''"
:disabled="count(@js($key)) === 0"
value="{{ $selectedMap[$key][0] ?? '' }}"
@if ($selectedMap[$key] === []) disabled @endif
/>
@elseif ($name && $facet['operators'] !== [])
<input type="hidden" name="{{ $name }}[{{ $key }}][operator]" :value="operators[@js($key)]" value="{{ $operatorMap[$key] }}" />
<template x-for="value in (selected[@js($key)] || [])" :key="value">
<input type="hidden" name="{{ $name }}[{{ $key }}][values][]" :value="value" />
</template>
@elseif ($name)
<template x-for="value in (selected[@js($key)] || [])" :key="value">
<input type="hidden" name="{{ $name }}[{{ $key }}][]" :value="value" />
</template>
@endif
</div>
@endforeach
<button
type="button"
data-slot="filters-clear"
x-show="hasActive"
x-cloak
@click="clearAll()"
@class([
'inline-flex h-9 items-center gap-2 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring',
'shrink-0' => $isFlush,
])
>
@if ($isFlush)
<span class="max-sm:sr-only">{{ __('Clear filters') }}</span>
@else
{{ __('Clear filters') }}
@endif
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
/**
* Filters — a faceted filter bar (reui.io-style).
*
* State lives entirely inside Alpine's x-data: `open` holds the key of the
* currently open facet popover (or null), `selected` maps facet key -> array
* of chosen option values, and `query` is the live search term for the open
* popover. All listeners are declared in Blade (@click.outside / @keydown.escape),
* so there is nothing to tear down.
*
* Facet types (`types`, only for facets that are not plain option lists):
* `text` keeps its applied text as a one-element list (a draft while the
* popover is open), `search` takes its values from the <x-ui.combobox> inside
* the popover. `labels` maps each facet's values to their labels, so the
* trigger names what was chosen. Opening a facet moves focus into its first
* text field; Escape closes it and returns focus to its trigger.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiFilters', (config = {}) => ({
open: null,
query: '',
selected: { ...(config.selected ?? {}) },
// Only facets that opt in to operators have an entry here.
operators: { ...(config.operators ?? {}) },
operatorLabels: config.operatorLabels ?? {},
types: config.types ?? {},
labels: Object.fromEntries(Object.entries(config.labels ?? {}).map(([key, map]) => [key, { ...map }])),
texts: config.texts ?? {},
// Text facets: the typed text while the popover is open.
drafts: {},
/**
* Announce a change from the root. $dispatch would fire on the
* control that ran the handler (a checkbox or the operator select),
* whose own @change listener would then run again and recurse.
*/
emit(detail) {
this.$root.dispatchEvent(new CustomEvent('change', { detail, bubbles: true }));
},
typeOf(key) {
return this.types[key] ?? 'options';
},
trigger(key) {
return [...this.$root.querySelectorAll('[data-slot="filters-trigger"]')].find((el) => el.dataset.facet === String(key)) ?? null;
},
popover(key) {
return this.trigger(key)?.parentElement?.querySelector('[data-slot="filters-popover"]') ?? null;
},
openFacet(key) {
const opening = this.open !== key;
this.open = opening ? key : null;
this.query = '';
if (!opening) return;
if (this.typeOf(key) === 'text') {
this.drafts = { ...this.drafts, [key]: (this.selected[key] || [])[0] ?? '' };
}
this.$nextTick(() => {
if (this.open !== key) return;
this.popover(key)?.querySelector('input[type="text"]')?.focus();
});
},
/** Close the open facet; from the keyboard (Escape) focus returns to its trigger. */
close(returnFocus = false) {
const key = this.open;
this.open = null;
this.query = '';
if (returnFocus && key !== null) this.trigger(key)?.focus();
},
/** Text facet: keep the trimmed draft (empty clears), report it and close. */
applyText(key) {
const text = String(this.drafts[key] ?? '').trim();
const values = text === '' ? [] : [text];
this.selected = { ...this.selected, [key]: values };
this.emit({ key, values, value: text, operator: this.operators[key] ?? null, selected: this.selected });
this.close(true);
},
/**
* Search facet: the combobox reported a change ({ value, label } or
* { values, items }). Keep the values and their labels.
*/
pickSearch(key, detail) {
// The combobox input's own native change event carries no detail.
if (!detail || (detail.values === undefined && detail.value === undefined)) return;
const items = detail.values !== undefined
? (detail.items ?? detail.values.map((value) => ({ value, label: value })))
: (detail.value === '' || detail.value === null ? [] : [{ value: detail.value, label: detail.label ?? detail.value }]);
const values = items.map((item) => String(item.value));
const facetLabels = { ...(this.labels[key] ?? {}) };
items.forEach((item) => { facetLabels[String(item.value)] = String(item.label ?? item.value); });
this.labels = { ...this.labels, [key]: facetLabels };
this.selected = { ...this.selected, [key]: values };
this.emit({
key,
values,
operator: this.operators[key] ?? null,
selected: this.selected,
labels: this.labelsFor(key, values),
});
},
labelsFor(key, values) {
return Object.fromEntries(values.map((value) => [value, this.labels[key]?.[String(value)] ?? String(value)]));
},
/** Empty the combobox of a search facet without a change event from it. */
resetSearch(key) {
const element = this.popover(key)?.querySelector('[data-slot="combobox"]');
const combobox = element && window.Alpine?.$data ? window.Alpine.$data(element) : null;
if (!combobox) return;
combobox.model = combobox.multiple ? [] : '';
combobox.query = '';
},
toggleOption(key, value) {
const current = this.selected[key] ? [...this.selected[key]] : [];
const index = current.indexOf(value);
if (index === -1) {
current.push(value);
} else {
current.splice(index, 1);
}
this.selected = { ...this.selected, [key]: current };
this.emit({ key, values: current, operator: this.operators[key] ?? null, selected: this.selected });
},
setOperator(key, operator) {
if (!(key in this.operators) || !(operator in this.operatorLabels)) return;
this.operators = { ...this.operators, [key]: operator };
this.emit({
key,
values: [...(this.selected[key] || [])],
operator,
selected: this.selected,
});
},
operatorLabel(key) {
return this.operatorLabels[this.operators[key]] ?? '';
},
isChecked(key, value) {
return (this.selected[key] || []).includes(value);
},
count(key) {
return (this.selected[key] || []).length;
},
preview(key) {
const values = this.selected[key] || [];
if (values.length <= 2) {
return values.map((value) => this.labels[key]?.[String(value)] ?? String(value)).join(', ');
}
return String(this.texts.many ?? ':count selected').replace(':count', String(values.length));
},
matches(label) {
const q = this.query.trim().toLowerCase();
if (q === '') {
return true;
}
return String(label).toLowerCase().includes(q);
},
hasVisible(labels) {
return (labels || []).some((label) => this.matches(label));
},
clearFacet(key) {
this.selected = { ...this.selected, [key]: [] };
const detail = { key, values: [], operator: this.operators[key] ?? null, selected: this.selected };
const type = this.typeOf(key);
if (type === 'text') {
this.drafts = { ...this.drafts, [key]: '' };
detail.value = '';
}
if (type === 'search') {
this.resetSearch(key);
detail.labels = {};
}
this.emit(detail);
},
clearAll() {
const cleared = {};
Object.keys(this.selected).forEach((key) => {
cleared[key] = [];
if (this.typeOf(key) === 'search') this.resetSearch(key);
});
this.drafts = {};
this.selected = cleared;
this.open = null;
this.emit({ key: null, values: [], selected: this.selected });
},
get hasActive() {
return Object.values(this.selected).some((values) => (values || []).length > 0);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.0 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0