Forms
Combobox code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/combobox.blade.php
@props([
'name' => null,
'options' => [],
'value' => null,
'placeholder' => 'Search...',
'size' => 'base',
'invalid' => false,
'disabled' => false,
])
@php
/*
| Searchable / autocomplete select. Filters options as you type, navigate
| with the arrow keys and select with Enter. Submits the chosen key via a
| hidden input bound to `name`.
|
| Pass `options` as [value => label].
|
| Usage:
| <x-ui.combobox name="country" :options="['us' => 'United States', 'gb' => 'United Kingdom']" placeholder="Select a country" />
*/
$sizes = [
'sm' => 'text-sm py-1.5',
'base' => 'text-sm py-2.5',
'lg' => 'text-base py-3',
];
$ring = $invalid
? 'border-red-400 dark:border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-700 focus:border-brand-500 focus:ring-brand-500';
$classes = implode(' ', [
'block w-full rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 shadow-sm ps-3.5 pe-10 transition focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed',
$sizes[$size] ?? $sizes['base'],
$ring,
]);
$optionList = collect($options)->map(fn ($label, $key) => ['value' => (string) $key, 'label' => (string) $label])->values()->all();
$listboxId = 'combobox-'.substr(md5($name.$placeholder.serialize($optionList)), 0, 10);
$normalizedName = $name
? trim((string) preg_replace('/[^A-Za-z0-9\-_:.]+/', '-', str_replace(['[', ']'], '', (string) $name)), '-')
: null;
$inputId = $attributes->get('id') ?: $normalizedName;
$providedAriaLabel = $attributes->get('aria-label');
$providedAriaLabelledBy = $attributes->get('aria-labelledby');
$fallbackLabel = null;
if (! $providedAriaLabel && ! $providedAriaLabelledBy && ! $attributes->get('id')) {
$fallbackLabel = $placeholder
? rtrim((string) $placeholder, '. ')
: ($name ? Str::headline(str_replace(['-', '_'], ' ', $normalizedName ?? (string) $name)) : null);
}
@endphp
<div
x-data="{
open: false,
search: '',
selected: @js($value !== null ? (string) $value : ''),
highlighted: 0,
options: @js($optionList),
disabled: @js((bool) $disabled),
init() {
this.search = this.selectedLabel;
},
get filtered() {
// Show every option when the field is empty or still showing the
// current selection, otherwise filter by the typed query.
if (this.search === '' || this.search === this.selectedLabel) { return this.options; }
const q = this.search.toLowerCase();
return this.options.filter(o => o.label.toLowerCase().includes(q));
},
get selectedLabel() {
const match = this.options.find(o => o.value === this.selected);
return match ? match.label : '';
},
get activeId() {
return this.open && this.filtered[this.highlighted] ? '{{ $listboxId }}-' + this.filtered[this.highlighted].value : null;
},
openList() {
if (this.disabled) { return; }
this.open = true;
this.highlighted = Math.max(0, this.filtered.findIndex(o => o.value === this.selected));
this.$nextTick(() => this.$refs.input.select());
},
choose(option) {
this.selected = option.value;
this.search = option.label;
this.open = false;
},
close() {
this.open = false;
// Revert any half-typed query back to the current selection.
this.search = this.selectedLabel;
},
move(dir) {
if (! this.open) { this.openList(); return; }
const count = this.filtered.length;
if (count === 0) { return; }
this.highlighted = (this.highlighted + dir + count) % count;
},
pick() {
if (this.open && this.filtered[this.highlighted]) {
this.choose(this.filtered[this.highlighted]);
}
},
}"
@click.outside="close()"
{{ $attributes->only('class')->merge(['class' => 'relative']) }}
>
@if ($name)
<input type="hidden" name="{{ $name }}" :value="selected">
@endif
<input
type="text"
autocomplete="off"
x-ref="input"
@disabled($disabled)
@if ($inputId) id="{{ $inputId }}" @endif
@if ($fallbackLabel) aria-label="{{ $fallbackLabel }}" @endif
@if ($invalid) aria-invalid="true" @endif
role="combobox"
aria-autocomplete="list"
aria-controls="{{ $listboxId }}"
:aria-expanded="open.toString()"
:aria-activedescendant="activeId"
placeholder="{{ $placeholder }}"
x-model="search"
@focus="openList()"
@click="openList()"
@input="open = true; highlighted = 0"
@keydown.arrow-down.prevent="move(1)"
@keydown.arrow-up.prevent="move(-1)"
@keydown.enter.prevent="pick()"
@keydown.escape.prevent="close()"
@keydown.tab="close()"
class="{{ $classes }}"
{{ $attributes->except(['class', 'id', 'aria-label']) }}
>
<span class="pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3 text-gray-400 dark:text-gray-500">
<x-ui.icon name="caret-down" weight="bold" size="sm" ::class="open ? 'rotate-180 transition-transform' : 'transition-transform'" />
</span>
<div
x-show="open" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
id="{{ $listboxId }}"
role="listbox"
class="absolute z-50 mt-2 max-h-60 w-full overflow-auto rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-1.5 shadow-lg ring-1 ring-black/5"
>
<template x-for="(option, index) in filtered" :key="option.value">
<button
type="button"
:id="'{{ $listboxId }}-' + option.value"
role="option"
:aria-selected="selected === option.value"
@click="choose(option)"
@mouseenter="highlighted = index"
:class="{
'bg-gray-100 dark:bg-gray-800': highlighted === index,
'text-brand-600 dark:text-brand-400 font-medium': selected === option.value,
'text-gray-700 dark:text-gray-200': selected !== option.value,
}"
class="flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition cursor-pointer"
>
<span x-text="option.label"></span>
<span x-show="selected === option.value" class="text-brand-500">
<x-ui.icon name="check" weight="bold" size="sm" />
</span>
</button>
</template>
<p x-show="filtered.length === 0" class="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No results found.</p>
</div>
</div>