Combobox
A searchable select for long lists: single or multiple selection, removable chips, options with descriptions and groups, search through a Livewire method or a JSON URL, and adding values that are not in the list. Search ignores letter case and Turkish letters; the component is fully usable with a keyboard and screen readers.
Single selection
Pass the options in options as a value => label array (or a collection via pluck()); value sets the initial selection. Click the box and start typing: search ignores letter case and Turkish letters (typing "sisli" finds "Şişli Branch", typing "igdir" finds "Iğdır Branch"), and the matching part is highlighted. The Down/Up arrows, Home/End, Enter and Escape operate the list from the keyboard; Enter does not submit the form.
<acun:combobox name="branch_id" label="Branch" value="2" :options="[
1 => 'Kadıköy Branch', 2 => 'Beşiktaş Branch', 3 => 'Üsküdar Branch',
4 => 'Çankaya Branch', 5 => 'Karşıyaka Branch', 6 => 'Nilüfer Branch', 7 => 'Ataşehir Branch',
8 => 'Şişli Branch', 9 => 'Çamlıca Branch', 10 => 'Iğdır Branch',
]" />
| Prop | Values |
|---|---|
options | value => label, or option arrays |
value | Selected value (compared as a string) |
Multiple selection
With multiple, selections appear inside the box as removable chips; when they do not fit, the box grows line by line. The list stays open after a selection so you can pick several in a row; clicking a selected option again removes it. Backspace in an empty box removes the last selection. max-chips limits how many chips are shown; the rest are counted, e.g. "+2" (hover to see their names).
<acun:combobox name="branches" label="Branches" multiple clearable :max-chips="3" :value="[1, 3, 4, 8, 9]" :options="[
1 => 'Kadıköy Branch', 2 => 'Beşiktaş Branch', 3 => 'Üsküdar Branch',
4 => 'Çankaya Branch', 5 => 'Karşıyaka Branch', 6 => 'Nilüfer Branch', 7 => 'Ataşehir Branch',
8 => 'Şişli Branch', 9 => 'Çamlıca Branch', 10 => 'Iğdır Branch',
]" />
| Prop | Values |
|---|---|
multiple | Multiple selection; the value is an array |
max-chips | Number of visible chips |
clearable | × that clears everything |
Rich options and groups
Options can also be arrays: besides value and label they accept description (a second line; also searched), avatar (a round image URL), icon (a Heroicons name), disabled and group. Options with the same group value are gathered under a heading that sticks to the top while the list scrolls. Disabled options are visible but cannot be selected; the keyboard skips them. Nested arrays (['Managers' => [1 => 'Ayşe', …]]) become groups, and enum lists such as Status::cases() become options (a label() method, if present, provides the text); arrays with id and name fields and models are read too.
<acun:combobox name="assignee" label="Assignee" placeholder="Select a staff member" :options="[
['value' => 1, 'label' => 'Ayşe Yılmaz', 'description' => '[email protected]', 'icon' => 'shield-check', 'group' => 'Managers'],
['value' => 2, 'label' => 'Mehmet Kaya', 'description' => '[email protected]', 'icon' => 'shield-check', 'group' => 'Managers'],
['value' => 3, 'label' => 'Zeynep Arslan', 'description' => 'On leave', 'icon' => 'briefcase', 'group' => 'Sales team', 'disabled' => true],
['value' => 4, 'label' => 'Emre Demir', 'description' => '[email protected]', 'icon' => 'briefcase', 'group' => 'Sales team'],
['value' => 5, 'label' => 'Şule Öztürk', 'description' => '[email protected]', 'icon' => 'briefcase', 'group' => 'Sales team'],
['value' => 6, 'label' => 'Gökhan Çelik', 'description' => 'Accounting', 'icon' => 'calculator', 'group' => 'Head office'],
['value' => 7, 'label' => 'İsmail Işık', 'description' => 'Technical support', 'icon' => 'wrench-screwdriver', 'group' => 'Head office'],
]" />
| Prop | Values |
|---|---|
description | Second line, searched |
avatar | Image URL |
icon | Heroicons name |
disabled | Cannot be selected |
group | Gathers options under a heading |
Search through a Livewire method
search is the name of a method on the Livewire component that contains the combobox. As the user types (after a 300 ms wait by default, changed with debounce), the method is called with the typed text and the options it returns are listed; while waiting, a spinner shows in the box, and "Loading…" appears if the list is empty. The list is also queried with an empty string each time it opens (unless min-chars is set). Selected values keep their labels even when the results change; selected-options provides the label of the selected value when the page first loads. The preview on this page uses a fake search in the browser instead of a server.
<acun:combobox
wire:model="assigneeId"
label="Assignee"
search="searchStaff"
:selected-options="Staff::whereKey($assigneeId)->pluck('name', 'id')"
placeholder="Search staff"
clearable
/>
| Prop | Values |
|---|---|
search | Name of the Livewire method |
selected-options | Labels of the selected values |
debounce | 300 (ms) |
min-chars | 0 |
The search method
The method takes a single parameter: the text typed in the box (string $query; it may be empty). It returns the option list: [['value' => 1, 'label' => 'Ayşe Yılmaz', 'description' => …], …], a value => label array or ComboboxOptions::from(…) (which turns icon names into drawable paths). #[Json] runs the method without re-rendering the component and returns the result directly (#[Renderless] works too). When a selection is made, the box does not send a new search at the same time; the wire:model.live change goes in its own request and the page re-renders, and the list is queried again when the user types or reopens it. If order matters, return a list: an array with numeric keys (pluck()) becomes an object in JSON, and the browser sorts its keys. If you search with the Acun UI search infrastructure (the SearchManager driver and search columns added with searchColumn(); see the Search guide), the server also finds "Ayşe" from "ayse".
use Acun\Ui\Search\SearchField;
use Acun\Ui\Search\SearchManager;
use Acun\Ui\Search\SearchMode;
use Acun\Ui\Support\ComboboxOptions;
use Livewire\Attributes\Json;
use Livewire\Component;
class TicketForm extends Component
{
public ?int $assigneeId = null;
// <acun:combobox search="searchStaff" />: receives the typed text, returns the options.
#[Json]
public function searchStaff(string $query): array
{
// name_search and email_search: $table->searchColumn('name'), searchColumn('email') in the migration.
$staff = app(SearchManager::class)->driver()
->apply(Staff::query(), $query, [SearchField::make('name', SearchMode::Contains), SearchField::make('email')])
->orderBy('name')
->limit(20)
->get();
return ComboboxOptions::from($staff->map(fn (Staff $person) => [
'value' => $person->id,
'label' => $person->name,
'description' => $person->email,
'group' => $person->department,
]));
}
}
Search through a JSON URL
On pages without Livewire, url is a JSON URL; the typed text is appended as ?q= (other parameters in the URL are kept). The response can be an option list, { "data": [...] } (API resources, paginators) or a value => label object; records shaped like { id, name } are read too. min-chars sets the minimum number of characters before searching; a stale response that arrives late does not overwrite newer results. The preview answers the URL in the browser.
<acun:combobox name="province_id" label="Province" url="/api/provinces" :min-chars="2" placeholder="Search provinces" clearable />
| Prop | Values |
|---|---|
url | JSON URL (?q= is appended) |
min-chars | Minimum characters before searching |
Endpoint
The URL receives the typed text in the q parameter and returns the options. The session cookie is sent (same-origin), so routes behind the auth middleware work too.
use Illuminate\Http\Request;
Route::get('/api/provinces', function (Request $request) {
return Province::query()
->where('name', 'like', '%'.$request->query('q').'%')
->orderBy('name')
->limit(20)
->get()
->map(fn (Province $province) => ['value' => $province->id, 'label' => $province->name]);
})->middleware('auth');
Adding new values
With creatable, text that has no exact match in the list gets an "Add “…”" row at the end of the list; differences in letter case and Turkish letters do not count as new (typing "WHOLESALE" finds "Wholesale" instead of adding it). Enter or a click selects the text as a value (value = text), and the root element dispatches a combobox-created event whose detail contains name and value.
<acun:combobox name="tags" label="Tags" multiple creatable :value="['urgent']" :options="[
'urgent' => 'Urgent', 'priority' => 'Priority', 'wholesale' => 'Wholesale', 'business' => 'Business customer',
]" />
| Prop | Values |
|---|---|
creatable | Adds text that is not in the list |
combobox-created | Event; detail: { name, value } |
Saving an added value
The Livewire component listens for the event with #[On]; the parameters are the fields of the event's detail. If, after saving, you set the property to the new record's ID and update the option list, the combobox picks up both on the next render.
use Livewire\Attributes\On;
#[On('combobox-created')]
public function tagAdded(string $name, string $value): void
{
if ($name === 'tags') {
Tag::firstOrCreate(['name' => $value]);
}
}
Clearing and limits
clearable adds a × on the right of the box that clears the selection; in single selection, Backspace in an empty box clears it too. max sets the maximum number of values in multiple selection: at the limit, a warning appears above the list and the other options become disabled, while selected ones can still be removed. :searchable="false" turns off typing, so short lists do not open the keyboard on phones. close-on-select sets whether the list closes after a selection (default: it closes in single selection and stays open in multiple selection).
<div class="grid gap-4 sm:grid-cols-2">
<acun:combobox name="priority" label="Priority" value="2" clearable :searchable="false" :options="[1 => 'Low', 2 => 'Normal', 3 => 'High', 4 => 'Urgent']" />
<acun:combobox name="categories" label="Categories (up to 3)" multiple :max="3" :value="['electronics', 'books']" :options="[
'electronics' => 'Electronics', 'clothing' => 'Clothing', 'books' => 'Books', 'home' => 'Home & living', 'sports' => 'Sports',
]" />
</div>
| Prop | Values |
|---|---|
clearable | Clears with × |
max | Maximum selections (multiple) |
searchable | true | false |
close-on-select | single: true · multiple: false |
Classic form
When name is set without wire:model, the value is submitted through hidden fields: name in single selection, and name[] for each value in multiple selection (on the server, $request->input('tags', [])). required also triggers browser validation: the form is not submitted without a selection. In the preview, the values to be submitted appear below the form.
<form action="/orders" method="POST" class="space-y-4">
@csrf
<acun:combobox name="branch_id" label="Branch" required :options="[1 => 'Kadıköy Branch', 2 => 'Beşiktaş Branch', 3 => 'Çankaya Branch']" />
<acun:combobox name="tags" label="Tags" multiple :options="['urgent' => 'Urgent', 'priority' => 'Priority', 'wholesale' => 'Wholesale']" />
<acun:button type="submit">Save</acun:button>
</form>
| Prop | Values |
|---|---|
name | Hidden field name (multiple: name[]) |
required | Selection is required |
Validation error and disabled
If there is a validation error for the field name (name or wire:model), the box gets a red border like acun:input and the message is shown below it; in multiple selection, tags.* errors are shown too. Without an error, the hint help text is shown. disabled locks the box; in a classic form, the value is not submitted.
<div class="grid gap-4 sm:grid-cols-2">
<acun:combobox name="branch" label="Branch" required :options="[1 => 'Kadıköy Branch', 2 => 'Beşiktaş Branch']" />
<acun:combobox name="locked_branch" label="Branch (locked)" value="2" disabled hint="Cannot be changed once the order is being prepared." :options="[1 => 'Kadıköy Branch', 2 => 'Beşiktaş Branch']" />
</div>
| Prop | Values |
|---|---|
hint | Shown when there is no error |
disabled | Locks; the value is not submitted |
Inside a modal, drawer or table
The list is drawn above the page (with fixed positioning) and opens below the box, or above it when there is no room; so the overflow boundary of a modal, a drawer or a horizontally scrolling table does not clip it. The list follows the box as the page or modal scrolls. While the list is open, Escape closes only the list; the modal stays open. On phones, the list opens below the box at its width, and rows are at least 40 px tall.
<div class="flex flex-wrap gap-2">
<acun:modal.trigger name="assign-ticket"><acun:button>Assign ticket</acun:button></acun:modal.trigger>
<acun:drawer.trigger name="ticket-filters"><acun:button variant="white">Filters</acun:button></acun:drawer.trigger>
</div>
<acun:modal name="assign-ticket" title="Assign ticket">
<div class="space-y-4">
<acun:combobox name="agent" label="Agent" :options="[1 => 'Ayşe Yılmaz', 2 => 'Mehmet Kaya', 3 => 'Zeynep Arslan', 4 => 'Emre Demir', 5 => 'Şule Öztürk', 6 => 'Gökhan Çelik', 7 => 'İsmail Işık', 8 => 'Ömer Güneş']" />
<acun:combobox name="ticket_tags" label="Tags" multiple :options="['billing' => 'Billing', 'return' => 'Return', 'shipping' => 'Shipping', 'technical' => 'Technical issue']" />
</div>
<acun:slot:footer>
<acun:button variant="white" x-on:click="AcunUI.modal.close('assign-ticket')">Cancel</acun:button>
<acun:button>Assign</acun:button>
</acun:slot:footer>
</acun:modal>
<acun:drawer name="ticket-filters" title="Filters">
<acun:combobox name="statuses" label="Status" multiple :options="['pending' => 'Pending', 'reviewing' => 'Under review', 'approved' => 'Approved', 'rejected' => 'Rejected']" />
</acun:drawer>
<div class="overflow-x-auto rounded-sm ring-1 ring-gray-200 dark:ring-zinc-700">
<table class="w-full min-w-[34rem] text-left text-sm">
<thead class="bg-gray-50 text-xs text-gray-500 dark:bg-zinc-800 dark:text-zinc-400">
<tr><th class="px-3 py-2 font-medium">Ticket</th><th class="px-3 py-2 font-medium">Status</th><th class="px-3 py-2 font-medium">Agent</th></tr>
</thead>
<tbody class="text-gray-700 dark:text-zinc-300">
<tr>
<td class="px-3 py-2">#1042 · Return request</td>
<td class="px-3 py-2">Pending</td>
<td class="w-56 px-3 py-2"><acun:combobox name="agent_1042" aria-label="Agent" :options="[1 => 'Ayşe Yılmaz', 2 => 'Mehmet Kaya', 3 => 'Zeynep Arslan', 4 => 'Emre Demir', 5 => 'Şule Öztürk', 6 => 'Gökhan Çelik', 7 => 'İsmail Işık', 8 => 'Ömer Güneş']" /></td>
</tr>
</tbody>
</table>
</div>
| Prop | Values |
|---|---|
acun:modal · acun:drawer | Opens without clipping |
overflow-x-auto | The table wrapper does not clip it |
With Alpine
On Alpine pages, x-model binds to an outside variable: a value in single selection, an array in multiple selection. The values of value => label arrays are strings ('3'); since comparison is always done as strings, the number 3 selects the same option. Other attributes such as class and data-* are added to the outer wrapper. When the user changes the selection, the root element also dispatches a bubbling change event.
<div x-data="{ branch: '3', regions: [] }" class="space-y-3">
<acun:combobox x-model="branch" label="Branch" :options="[1 => 'Kadıköy Branch', 2 => 'Beşiktaş Branch', 3 => 'Üsküdar Branch']" />
<acun:combobox x-model="regions" label="Regions" multiple :options="[1 => 'Marmara', 2 => 'Aegean', 3 => 'Central Anatolia']" />
<p class="text-sm text-gray-600 dark:text-zinc-400">branch: <span x-text="JSON.stringify(branch)"></span> · regions: <span x-text="JSON.stringify(regions)"></span></p>
</div>
| Prop | Values |
|---|---|
x-model | single: value · multiple: array |
change | When the user makes a selection (bubbles) |
With Livewire
wire:model, .live and .live.blur work (.blur alone does not send a request in Livewire 4); the number 3 coming from Livewire selects the option with the '3' key. The inner part of the box is protected with wire:ignore: when the component re-renders, the open list, the typed text and the selection stay intact. options, disabled and the validation error, on the other hand, are updated with each render; this is how dependent selects work, such as a district list that changes when a province is selected.
<acun:combobox wire:model.live="provinceId" label="Province" :options="$provinces->pluck('name', 'id')" />
<acun:combobox wire:model.live.blur="districtId" label="District" :options="$districts->pluck('name', 'id')" :disabled="! $provinceId" />
<acun:combobox wire:model="staff" label="Staff" multiple :options="$staff->pluck('name', 'id')" />
| Prop | Values |
|---|---|
wire:model | .live · .live.blur · .live.debounce.500ms |
wire:key | Set it when rendering in a loop |
Props and slots
The values the component accepts.
| Prop | Default | Description |
|---|---|---|
label | null | Label above the box |
options | [] | value => label; option arrays; nested array (group); enum; records with id/name fields |
value | null | Initial value (multiple: array); when wire:model / x-model is present, it takes precedence |
multiple | false | Multiple selection; selections are removable chips |
placeholder | Select… | Text shown when nothing is selected |
search-placeholder | Search… | Hint in the empty box while the list is open |
empty | No results | Text shown when nothing matches |
hint | null | Help text below, shown when there is no error |
clearable | false | × that clears the selection (Backspace too in single selection) |
creatable | false | Adds text that is not in the list; combobox-created event |
searchable | true | false: typing disabled (short lists) |
max | null | multiple: maximum selections; a warning is shown at the limit |
max-chips | null | multiple: number of visible chips, the rest as "+N" |
close-on-select | single: true · multiple: false | Whether the list closes after a selection |
search | null | Livewire method: fn (string $query): array (option list) |
url | null | JSON URL; called with ?q= |
debounce | 300 | Wait for remote search (ms) |
min-chars | 0 | Minimum characters before a remote search |
selected-options | [] | Labels of the selected values in remote lists |
limit | 200 | Maximum options drawn at once in a local list |
disabled | false | Locks; the hidden field is not submitted |
required | false | * on the label; browser validation and aria-required |
name | null | Classic form field (multiple: name[]); error key |
wire:model / x-model | — | single: value · multiple: array; .live and .live.blur work |
Option keys | — | value, label, description, avatar, icon, disabled, group |
Event: combobox-created | — | detail: { name, value }; in Livewire, #[On('combobox-created')] |
Event: change | — | From the root element when the user changes the selection (bubbles) |