Skip to main content

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>