Skip to content

Instantly share code, notes, and snippets.

@codemonkey76
Created September 28, 2022 04:47
Show Gist options
  • Save codemonkey76/3657d36de46588cab6962c878874ac17 to your computer and use it in GitHub Desktop.
Save codemonkey76/3657d36de46588cab6962c878874ac17 to your computer and use it in GitHub Desktop.
Searchable select component
import Alpine from 'alpinejs'
import select from './components/select'
window.Alpine = Alpine
Alpine.data('select', select)
Alpine.start()
@props([
'keyField' => 'id',
'nameField' => 'name',
'secondaryField' => '',
'name' => '',
'options' => $options,
'label' => 'Select',
'id' => 'select'
])
<div
x-data="select({
options: {{ json_encode($options) }},
name: '{{ $name }}',
value: @entangle($attributes->wire('model')),
keyField: '{{ $keyField }}',
nameField: '{{ $nameField }}',
secondaryField: '{{ $secondaryField }}',
id: '{{ $id }}',
label: '{{ $label }}'
})"
x-cloak
@click.away.stop="close()"
@keydown.esc="close()"
@keydown.arrow-up.prevent="focusPrevious()"
@keydown.arrow-down.prevent="focusNext()"
>
<label :for="id" class="block text-sm font-medium text-gray-700" x-text="label"></label>
<div class="relative mt-1">
<input
@click="doSearch()"
x-show="!isSearching"
:value="selectedOptionName"
type="text"
class="w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
readonly
>
<input x-ref="search"
x-show="isSearching"
x-model="search"
@keydown="doSearch()"
@click="toggle()"
class="w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
>
<button @click="toggleOrClear()" type="button"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
<svg x-show="hasSearch" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-gray-400">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg x-show="!hasSearch" class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
clip-rule="evenodd"/>
</svg>
</button>
<ul
x-show="isOpen"
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
<template x-for="option in filteredOptions">
<li
class="relative cursor-default select-none py-2 pl-3 pr-9"
:class="{'text-white bg-indigo-600': focusedOption === option, 'text-gray-900': focusedOption !== option }"
@mouseenter="focusOption(option)"
@mouseleave="removeFocus()"
@click="selectOption(option)"
>
<div class="flex">
<span
class="truncate"
:class="{'font-semibold': selectedOption === option}"
x-text="option[nameField]"></span>
<span
class="ml-2 truncate"
:class="{'text-indigo-200': focusedOption === option, 'text-gray-500': focusedOption !== option }"
x-text="option[secondaryField]"></span>
<span
x-show="selectedOption === option"
class="absolute inset-y-0 right-0 flex items-center pr-4"
:class="{'text-white': focusedOption === option, 'text-indigo-600': focusedOption !== option}">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"/>
</svg>
</span>
</div>
</li>
</template>
</ul>
</div>
</div>
export default (config) => ({
keyField: config.keyField,
nameField: config.nameField,
secondaryField: config.secondaryField,
options: config.options,
id: config.id,
label: config.label,
isOpen: false,
isSearching: true,
focusedOption: null,
selectedOption: null,
search: '',
get filteredOptions() {
return this.options.filter(
i => i[this.nameField].toLowerCase().includes(this.search.toLowerCase())
)
},
get selectedOptionName() {
if (!this.selectedOption) return '';
return this.selectedOption[this.nameField];
},
get hasSearch() {
return this.search !== ''
},
close: function() {
this.isOpen = false
},
open: function() {
this.isOpen = true
},
doSearch: function() {
this.open()
this.search = this.selectedOption[this.nameField]
this.deselectOption()
},
toggleOrClear: function() {
if (!this.hasSearch) return this.toggle()
this.search = ''
},
toggle: function() {
this.isOpen = !this.isOpen
},
removeFocus: function() {
this.focusedOption = null
},
focusOption: function(option) {
this.focusedOption = option
},
deselectOption: function() {
this.selectedOption = null
this.isSearching = true
this.$nextTick(() => this.$refs.search.focus())
},
selectOption: function(option) {
if (this.selectedOption === option) return this.deselectOption()
this.selectedOption = option
this.isSearching = false
this.close()
this.search = this.selectedOption[this.nameField]
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment