Skip to content
Brok UI

Loading…

No results

Filters

Open source

A faceted filter toolbar of popover facets with searchable multi-select option lists, count badges, and a clear-all action.

Version
v1.3.0
Stability
stable
License
MIT
Related
Active Filters
Native Select
Checkbox

Preview

No results.

No results.

No results.

previews.components.filters.default.blade.php Blade
<div class="w-full max-w-2xl">
    <x-ui.filters />
</div>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/filters.blade.php
  • js resources/js/ui/filters.js
Registry dependencies
badge combobox
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

filters.md
# 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

async.blade.php Blade
{{-- 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>
flush.blade.php Blade
<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.blade.php Blade
{{-- 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>
preset.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

filters filters-apply filters-clear filters-operator filters-operator-label filters-option-count filters-popover filters-search filters-text filters-trigger

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

Searchable selection and filtering

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
root filters-trigger filters-popover filters-clear filters-operator filters-operator-label filters-option-count filters-text filters-apply filters-search
Theming hooks
filters filters-apply filters-clear filters-operator filters-operator-label filters-option-count filters-popover filters-search filters-text filters-trigger

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/filters.blade.php Blade
{{--
    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>
resources/js/ui/filters.js JS
/**
 * 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