Created
July 3, 2025 01:07
-
-
Save Parables/a9826ecbfa4240470fff0c28b38555c8 to your computer and use it in GitHub Desktop.
Laravel Blade Typeahaed/Combobox with Pure JS - Zero dependencies
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@props([ | |
'name', | |
'options' => [], | |
'label' => null, | |
'required' => null, | |
'nativeRequired' => false, | |
'width' => 'w-full', | |
'inputClass' => '', | |
'selected' => null, | |
'placeholder' => 'Select an option...', | |
]) | |
@php | |
$id = $attributes->get('id', $name); | |
$gridCols = match (true) { | |
!empty($prefix) && !empty($suffix) => 'grid-cols-[auto_1fr_auto]', | |
!empty($prefix) => 'grid-cols-[auto_1fr]', | |
!empty($suffix) => 'grid-cols-[1fr_auto]', | |
default => 'grid-cols-[1fr]', | |
}; | |
@endphp | |
{{-- | |
The root element that our JavaScript will attach to. | |
- `data-controller="combobox"`: Identifies this as a combobox component. | |
- `data-options` & `data-selected`: Passes PHP data to JavaScript. | |
--}} | |
<div class="flex flex-col f-text-12-14 {{ $width }}" data-controller="combobox" | |
data-options='@json($options)' data-selected='@json($selected)' id="{{ 'typeahead-' . $id }}"> | |
@if (!empty($label)) | |
<label for="{{ $id }}" class="text-primary font-semibold mb-5px">{!! $label !!} | |
<span class="text-red">{{ $required ? '*' : '' }}</span> | |
</label> | |
@endif | |
<div data-combobox-target="wrapper" class="relative"> | |
<div | |
class="grid {{ $gridCols }} f-p-10-14 rounded-10px bg-secondary font-semibold | |
@error($name) red-border-1 focus-within:red-border-1 | |
@else gray-800-border-1 focus-within:primary-border-1 @enderror"> | |
@isset($prefix) | |
<div class="flex items-center"> | |
{{ $prefix }} | |
</div> | |
@endisset | |
{{-- | |
The main input element. | |
- `data-combobox-target="input"`: Allows our JS to find it easily. | |
--}} | |
<input data-combobox-target="input" type="text" id="{{ $id }}" | |
name="{{ $name }}_display" @required($required && $nativeRequired) placeholder="{{ $placeholder }}" | |
autocomplete="off" role="combobox" aria-controls="options-{{ $id }}" aria-expanded="false" | |
class="w-full {{ $inputClass }} @error($name) text-red placeholder-red @enderror" | |
{{ $attributes->whereDoesntStartWith('wire:model') }} /> | |
@isset($suffix) | |
<div class="flex items-center"> | |
{{ $suffix }} | |
</div> | |
@endisset | |
</div> | |
{{-- | |
The dropdown list container. | |
- `data-combobox-target="list"`: Allows our JS to find and populate it. | |
- The `hidden` class is used to control visibility. | |
--}} | |
<div data-combobox-target="list" | |
class="absolute z-50 w-full mt-1 bg-black primary-border-1px rounded-md shadow-lg max-h-60 overflow-auto hidden" | |
id="options-{{ $id }}" role="listbox"> | |
{{-- Options will be rendered here by JavaScript --}} | |
</div> | |
</div> | |
{{-- The "empty" message template --}} | |
<template data-combobox-target="empty"> | |
<p class="p-4 text-sm text-gray-500 text-center"> | |
{{ $empty ?? 'No results found' }} | |
</p> | |
</template> | |
{{-- | |
The template for rendering each option. | |
- `data-combobox-target="template"`: Our JS reads this innerHTML to render options. | |
--}} | |
<template data-combobox-target="template"> | |
@isset($option) | |
{{ $option }} | |
@else | |
__label__ | |
@endisset | |
</template> | |
@error($name) | |
<span class="text-red">{{ $message }}</span> | |
@enderror | |
{{-- | |
The hidden input that holds the actual selected value for form submission. | |
- `data-combobox-target="hiddenInput"`: Allows our JS to set its value. | |
--}} | |
<input type="hidden" name="{{ $name }}" wire:model="{{ $name }}" | |
{{ $attributes->whereStartsWith('wire:model') }} data-combobox-target="hiddenInput" /> | |
</div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// NOTE: This is the first stable almost perfect working version | |
// The only issue with this was how to change the options after the component has been rendered | |
// The typeahead.v1.js addresses that issue so it is recommended to use that one instead of this | |
// Only use the typeahead.v0.js if you don't need to change the options dynamically | |
// | |
class Combobox { | |
constructor(element) { | |
this.element = element; | |
this.config = { | |
options: JSON.parse(element.dataset.options || "[]"), | |
selected: JSON.parse(element.dataset.selected || "null"), | |
}; | |
// Find all target elements within the component's scope | |
this.targets = { | |
wrapper: this.element.querySelector('[data-combobox-target="wrapper"]'), | |
input: this.element.querySelector('[data-combobox-target="input"]'), | |
list: this.element.querySelector('[data-combobox-target="list"]'), | |
hiddenInput: this.element.querySelector('[data-combobox-target="hiddenInput"]'), | |
template: this.element.querySelector('[data-combobox-target="template"]'), | |
empty: this.element.querySelector('[data-combobox-target="empty"]'), | |
}; | |
// State | |
this.open = false; | |
this.search = ""; | |
this.activeIndex = -1; | |
this.selectedOption = null; | |
// Normalize options once at the beginning | |
this.normalizedOptions = this.normalizeOptions(this.config.options); | |
this.filteredOptions = [...this.normalizedOptions]; | |
this.init(); | |
} | |
// --- Initialization --- | |
init() { | |
this.bindEvents(); | |
if (this.config.selected) { | |
const initialOption = this.normalizedOptions.find((opt) => String(opt.id) === String(this.config.selected)); | |
if (initialOption) { | |
this.selectOption(initialOption, false); // Select without dispatching change event on init | |
} | |
} | |
} | |
bindEvents() { | |
this.targets.wrapper.addEventListener("click", this.focusOnInput.bind(this)); | |
this.targets.input.addEventListener("focus", this.onFocus.bind(this)); | |
this.targets.input.addEventListener("blur", this.onBlur.bind(this)); | |
this.targets.input.addEventListener("keydown", this.onKeyDown.bind(this)); | |
this.targets.input.addEventListener("input", this.onSearch.bind(this)); | |
// Use mousedown to prevent blur event from firing before click | |
this.targets.list.addEventListener("mousedown", this.onOptionClick.bind(this)); | |
} | |
// --- State & Data Management --- | |
normalizeOptions(options) { | |
if (typeof options === "object") { | |
return Object.keys(options).map((key) => ({ | |
id: key, | |
label: options[key], | |
data: {}, | |
})); | |
} | |
if (Array.isArray(options)) { | |
return options.map((opt) => { | |
if (Array.isArray(opt)) { | |
return { | |
id: opt[0], | |
label: opt[1] ?? opt[0], | |
data: opt[2] ?? {}, | |
}; | |
} | |
if (typeof opt === "string") { | |
return { | |
id: opt, | |
label: opt, | |
data: {}, | |
}; | |
} | |
if (typeof opt === "object" && opt !== null) { | |
const { id, label, ...data } = opt; | |
return { | |
id: id ?? opt.value ?? label, | |
label: label ?? opt.name ?? opt.text ?? id, | |
data, | |
}; | |
} | |
return { | |
id: String(opt), | |
label: String(opt), | |
data: {}, | |
}; | |
}); | |
} | |
return []; | |
} | |
selectOption(option, dispatchEvent = true) { | |
if (!option) { | |
return; | |
} | |
this.selectedOption = option; | |
this.search = option.label; | |
this.targets.input.value = option.label; | |
this.targets.hiddenInput.value = option.id; | |
this.close(); | |
if (dispatchEvent) { | |
this.targets.hiddenInput.dispatchEvent( | |
new CustomEvent("combobox-change", { | |
bubbles: true, | |
detail: { id: option.id, label: option.label, data: option.data }, | |
}), | |
); | |
// Compatibility with Livewire's @entangle | |
this.targets.hiddenInput.dispatchEvent(new Event("input", { bubbles: true })); | |
} | |
} | |
// --- Event Handlers --- | |
onSearch(e) { | |
this.search = e.target.value; | |
this.activeIndex = -1; | |
if (!this.search.trim()) { | |
this.filteredOptions = [...this.normalizedOptions]; | |
this.selectedOption = null; | |
this.open = true; | |
} else { | |
this.filteredOptions = this.normalizedOptions.filter((option) => option.label.toLowerCase().includes(this.search.toLowerCase()) || this.searchInData(option.data, this.search)); | |
this.open = true; | |
this.selectedOption = this.filteredOptions[0]; | |
this.activeIndex++; | |
} | |
this.render(); | |
} | |
onOptionClick(e) { | |
const optionEl = e.target.closest('[role="option"]'); | |
if (optionEl && optionEl.dataset.index) { | |
const index = parseInt(optionEl.dataset.index, 10); | |
this.selectOption(this.filteredOptions[index]); | |
} | |
} | |
focusOnInput(e) { | |
this.targets.input.focus(); | |
} | |
onFocus() { | |
this.open = true; | |
if (!this.selectedOption) { | |
this.search = ""; | |
this.targets.input.value = ""; | |
this.filteredOptions = [...this.normalizedOptions]; | |
} | |
this.render(); | |
} | |
onBlur(e) { | |
// Timeout allows click event on options to fire before closing | |
setTimeout(() => { | |
if (!this.element.contains(document.activeElement)) { | |
this.close(); | |
// If nothing was selected, reset the input | |
if (this.selectedOption) { | |
this.targets.input.value = this.selectedOption.label; | |
} else { | |
this.targets.input.value = ""; | |
} | |
} | |
}, 150); | |
} | |
onKeyDown(e) { | |
switch (e.key) { | |
case "ArrowDown": | |
e.preventDefault(); | |
if (this.activeIndex < this.filteredOptions.length - 1) { | |
this.activeIndex++; | |
this.render(); | |
this.scrollIntoView(); | |
} | |
break; | |
case "ArrowUp": | |
e.preventDefault(); | |
if (this.activeIndex > 0) { | |
this.activeIndex--; | |
this.render(); | |
this.scrollIntoView(); | |
} | |
break; | |
case "Enter": | |
if (this.activeIndex >= 0) { | |
e.preventDefault(); | |
this.selectOption(this.filteredOptions[this.activeIndex]); | |
} | |
break; | |
case "Escape": | |
this.close(); | |
this.targets.input.blur(); | |
break; | |
} | |
} | |
// --- Rendering & DOM Manipulation --- | |
render() { | |
this.targets.list.innerHTML = ""; // Clear previous options | |
// Show/hide empty message | |
if (this.filteredOptions.length < 1) { | |
const noResults = document.createElement("div"); | |
noResults.innerHTML = this.targets.empty.innerHTML; | |
this.targets.list.appendChild(noResults); | |
} | |
this.filteredOptions.forEach((option, index) => { | |
const optionEl = document.createElement("div"); | |
optionEl.role = "option"; | |
optionEl.dataset.index = index; | |
optionEl.id = `option-${index}`; | |
optionEl.className = "cursor-pointer select-none relative py-2 pl-3 pr-9"; | |
optionEl.setAttribute("aria-selected", this.activeIndex === index); | |
if (this.activeIndex === index) { | |
optionEl.classList.add("bg-secondary", "text-primary", "font-semibold"); | |
} else { | |
optionEl.classList.add("text-gray-300"); | |
} | |
optionEl.innerHTML = this.renderTemplate(option); | |
if (this.selectedOption?.id === option.id) { | |
const checkIcon = ` | |
<span class="absolute inset-y-0 right-0 flex items-center pr-4 ${this.activeIndex === index ? "text-white" : ""}"> | |
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> | |
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> | |
</svg> | |
</span>`; | |
optionEl.insertAdjacentHTML("beforeend", checkIcon); | |
} | |
this.targets.list.appendChild(optionEl); | |
}); | |
if (this.open) { | |
this.targets.list.classList.remove("hidden"); | |
this.targets.input.setAttribute("aria-expanded", "true"); | |
} else { | |
this.targets.list.classList.add("hidden"); | |
this.targets.input.setAttribute("aria-expanded", "false"); | |
} | |
} | |
renderTemplate(option) { | |
const template = this.targets.template.innerHTML; | |
const compiled = template.replace(/__([^_]+)__/g, (match, key) => { | |
return option.data[key.toLowerCase()] ?? option[key.toLowerCase()] ?? match; | |
}); | |
return this.highlightMatches(compiled); | |
} | |
highlightMatches(content) { | |
if (!this.search.trim()) return content; | |
const regex = new RegExp(`(${this.escapeRegex(this.search)})`, "gi"); | |
return content.replace(regex, `<mark class="bg-yellow-200 rounded-sm p-0 m-0">$1</mark>`); | |
} | |
scrollIntoView() { | |
const activeOption = this.targets.list.querySelector(`#option-${this.activeIndex}`); | |
if (activeOption) { | |
activeOption.scrollIntoView({ block: "nearest" }); | |
} | |
} | |
close() { | |
this.open = false; | |
this.render(); | |
} | |
// --- Utilities --- | |
searchInData(data, search) { | |
return Object.values(data).some((value) => String(value).toLowerCase().includes(search.toLowerCase())); | |
} | |
escapeRegex(string) { | |
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
} | |
} | |
// --- Auto-Initializer --- | |
// Finds all combobox components on the page and initializes them. | |
const comboboxes = document.querySelectorAll('[data-controller="combobox"]'); | |
comboboxes.forEach((el) => new Combobox(el)); | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// NOTE: This is the recommended stable version... | |
Please use this one instead of typeahead.v0.js and typeahead.v2.js | |
class Combobox { | |
constructor(element) { | |
this.element = element; | |
this.config = { | |
options: JSON.parse(element.dataset.options || '[]'), | |
selected: JSON.parse(element.dataset.selected || 'null'), | |
}; | |
// Find all target elements within the component's scope | |
this.targets = { | |
wrapper: this.element.querySelector('[data-combobox-target="wrapper"]'), | |
input: this.element.querySelector('[data-combobox-target="input"]'), | |
list: this.element.querySelector('[data-combobox-target="list"]'), | |
hiddenInput: this.element.querySelector('[data-combobox-target="hiddenInput"]'), | |
template: this.element.querySelector('[data-combobox-target="template"]'), | |
empty: this.element.querySelector('[data-combobox-target="empty"]'), | |
}; | |
// Validate required targets exist | |
if (!this.targets.input || !this.targets.list || !this.targets.hiddenInput) { | |
console.error('Combobox: Required targets not found'); | |
return; | |
} | |
// State | |
this.open = false; | |
this.search = ''; | |
this.activeIndex = -1; | |
this.selectedOption = null; | |
this.isInitializing = true; | |
// Normalize options once at the beginning | |
this.normalizedOptions = this.normalizeOptions(this.config.options); | |
this.filteredOptions = [...this.normalizedOptions]; | |
this.init(); | |
} | |
// --- Initialization --- | |
init() { | |
this.bindEvents(); | |
if (this.config.selected) { | |
const initialOption = this.normalizedOptions.find(opt => | |
String(opt.id) === String(this.config.selected) | |
); | |
if (initialOption) { | |
this.selectOption(initialOption, false); // Select without dispatching change event on init | |
} | |
} | |
this.isInitializing = false; | |
} | |
bindEvents() { | |
// Bind events with proper context | |
this.boundFocusOnInput = this.focusOnInput.bind(this); | |
this.boundOnFocus = this.onFocus.bind(this); | |
this.boundOnBlur = this.onBlur.bind(this); | |
this.boundOnKeyDown = this.onKeyDown.bind(this); | |
this.boundOnSearch = this.onSearch.bind(this); | |
this.boundOnOptionClick = this.onOptionClick.bind(this); | |
this.targets.wrapper.addEventListener('click', this.boundFocusOnInput); | |
this.targets.input.addEventListener('focus', this.boundOnFocus); | |
this.targets.input.addEventListener('blur', this.boundOnBlur); | |
this.targets.input.addEventListener('keydown', this.boundOnKeyDown); | |
this.targets.input.addEventListener('input', this.boundOnSearch); | |
// Use mousedown to prevent blur event from firing before click | |
this.targets.list.addEventListener('mousedown', this.boundOnOptionClick); | |
} | |
// Add cleanup method for proper memory management | |
destroy() { | |
if (this.targets.wrapper) { | |
this.targets.wrapper.removeEventListener('click', this.boundFocusOnInput); | |
} | |
if (this.targets.input) { | |
this.targets.input.removeEventListener('focus', this.boundOnFocus); | |
this.targets.input.removeEventListener('blur', this.boundOnBlur); | |
this.targets.input.removeEventListener('keydown', this.boundOnKeyDown); | |
this.targets.input.removeEventListener('input', this.boundOnSearch); | |
} | |
if (this.targets.list) { | |
this.targets.list.removeEventListener('mousedown', this.boundOnOptionClick); | |
} | |
} | |
// --- State & Data Management --- | |
normalizeOptions(options) { | |
return options.map(opt => { | |
if (Array.isArray(opt)) { | |
return { id: opt[1] ?? opt[0], label: opt[0], data: opt[2] ?? {} }; | |
} | |
if (typeof opt === 'string') { | |
return { id: opt, label: opt, data: {} }; | |
} | |
if (typeof opt === 'object' && opt !== null) { | |
const { id, label, ...data } = opt; | |
return { | |
id: id ?? opt.value ?? label, | |
label: label ?? opt.name ?? opt.text ?? id, | |
data | |
}; | |
} | |
return { id: String(opt), label: String(opt), data: {} }; | |
}); | |
} | |
selectOption(option, dispatchEvent = true) { | |
if (!option) { | |
return; | |
} | |
this.selectedOption = option; | |
this.search = option.label; | |
this.targets.input.value = option.label; | |
this.targets.hiddenInput.value = option.id; | |
this.close(); | |
if (dispatchEvent && !this.isInitializing) { | |
this.element.dispatchEvent(new CustomEvent('combobox-change', { | |
bubbles: true, | |
detail: { id: option.id, label: option.label, data: option.data } | |
})); | |
// Compatibility with Livewire's @entangle | |
this.element.dispatchEvent(new Event('input', { bubbles: true })); | |
} | |
} | |
clearSelection(dispatchEvent = true) { | |
this.selectedOption = null; | |
this.search = ''; | |
this.targets.input.value = ''; | |
this.targets.hiddenInput.value = ''; | |
this.filteredOptions = [...this.normalizedOptions]; | |
this.activeIndex = -1; | |
if (dispatchEvent && !this.isInitializing) { | |
this.element.dispatchEvent(new CustomEvent('combobox-change', { | |
bubbles: true, | |
detail: { id: null, label: '', data: {} } | |
})); | |
this.element.dispatchEvent(new Event('input', { bubbles: true })); | |
} | |
this.render(); | |
} | |
// --- Event Handlers --- | |
onSearch(e) { | |
this.search = e.target.value; | |
this.activeIndex = -1; | |
if (!this.search.trim()) { | |
this.filteredOptions = [...this.normalizedOptions]; | |
// Only clear selection if we're actively searching, not on initial focus | |
if (this.selectedOption && this.selectedOption.label !== this.search) { | |
this.selectedOption = null; | |
} | |
this.open = true; | |
} else { | |
this.filteredOptions = this.normalizedOptions.filter(option => | |
option.label.toLowerCase().includes(this.search.toLowerCase()) || | |
this.searchInData(option.data, this.search) | |
); | |
this.open = true; | |
// Auto-select first option if current selection doesn't match search | |
if (this.filteredOptions.length > 0 && | |
(!this.selectedOption || !this.selectedOption.label.toLowerCase().includes(this.search.toLowerCase()))) { | |
this.activeIndex = 0; | |
} | |
} | |
this.render(); | |
} | |
onOptionClick(e) { | |
e.preventDefault(); // Prevent default to avoid any form submission issues | |
const optionEl = e.target.closest('[role="option"]'); | |
if (optionEl && optionEl.dataset.index !== undefined) { | |
const index = parseInt(optionEl.dataset.index, 10); | |
if (index >= 0 && index < this.filteredOptions.length) { | |
this.selectOption(this.filteredOptions[index]); | |
} | |
} | |
} | |
focusOnInput(e) { | |
// Prevent focusing if clicking on the dropdown list | |
if (!this.targets.list.contains(e.target)) { | |
this.targets.input.focus(); | |
} | |
} | |
onFocus() { | |
this.open = true; | |
if (!this.selectedOption) { | |
this.search = ''; | |
this.targets.input.value = ''; | |
this.filteredOptions = [...this.normalizedOptions]; | |
this.activeIndex = -1; | |
} | |
this.render(); | |
} | |
onBlur(e) { | |
// Timeout allows click event on options to fire before closing | |
setTimeout(() => { | |
if (!this.element.contains(document.activeElement)) { | |
this.close(); | |
// If nothing was selected and input has text, either select closest match or clear | |
if (!this.selectedOption && this.search.trim()) { | |
const exactMatch = this.normalizedOptions.find(opt => | |
opt.label.toLowerCase() === this.search.toLowerCase() | |
); | |
if (exactMatch) { | |
this.selectOption(exactMatch); | |
} else { | |
// Reset to previous selection or clear | |
this.targets.input.value = this.selectedOption ? this.selectedOption.label : ''; | |
this.search = this.selectedOption ? this.selectedOption.label : ''; | |
} | |
} else if (this.selectedOption) { | |
this.targets.input.value = this.selectedOption.label; | |
this.search = this.selectedOption.label; | |
} | |
} | |
}, 150); | |
} | |
onKeyDown(e) { | |
switch (e.key) { | |
case 'ArrowDown': | |
e.preventDefault(); | |
if (!this.open) { | |
this.open = true; | |
this.render(); | |
} else if (this.activeIndex < this.filteredOptions.length - 1) { | |
this.activeIndex++; | |
this.render(); | |
this.scrollIntoView(); | |
} | |
break; | |
case 'ArrowUp': | |
e.preventDefault(); | |
if (!this.open) { | |
this.open = true; | |
this.render(); | |
} else if (this.activeIndex > 0) { | |
this.activeIndex--; | |
this.render(); | |
this.scrollIntoView(); | |
} | |
break; | |
case 'Enter': | |
if (this.open && this.activeIndex >= 0 && this.filteredOptions[this.activeIndex]) { | |
e.preventDefault(); | |
this.selectOption(this.filteredOptions[this.activeIndex]); | |
} | |
break; | |
case 'Escape': | |
this.close(); | |
this.targets.input.blur(); | |
break; | |
case 'Tab': | |
// Allow natural tab behavior, but close dropdown | |
if (this.open) { | |
this.close(); | |
} | |
break; | |
} | |
} | |
// --- Rendering & DOM Manipulation --- | |
render() { | |
if (!this.targets.list) return; | |
this.targets.list.innerHTML = ''; // Clear previous options | |
// Show/hide empty message | |
if (this.filteredOptions.length < 1) { | |
const noResults = document.createElement('div'); | |
noResults.innerHTML = this.targets.empty ? this.targets.empty.innerHTML : '<p class="p-4 text-sm text-gray-500 text-center">No results found</p>'; | |
this.targets.list.appendChild(noResults); | |
} else { | |
this.filteredOptions.forEach((option, index) => { | |
const optionEl = document.createElement('div'); | |
optionEl.role = 'option'; | |
optionEl.dataset.index = index; | |
optionEl.id = `option-${index}`; | |
optionEl.className = 'cursor-pointer select-none relative py-2 pl-3 pr-9'; | |
optionEl.setAttribute('aria-selected', this.activeIndex === index ? 'true' : 'false'); | |
if (this.activeIndex === index) { | |
optionEl.classList.add('bg-secondary', 'text-primary', 'font-semibold'); | |
} else { | |
optionEl.classList.add('text-gray-300'); | |
} | |
optionEl.innerHTML = this.renderTemplate(option); | |
if (this.selectedOption?.id === option.id) { | |
const checkIcon = ` | |
<span class="absolute inset-y-0 right-0 flex items-center pr-4 ${this.activeIndex === index ? 'text-white' : ''}"> | |
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> | |
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> | |
</svg> | |
</span>`; | |
optionEl.insertAdjacentHTML('beforeend', checkIcon); | |
} | |
this.targets.list.appendChild(optionEl); | |
}); | |
} | |
if (this.open) { | |
this.targets.list.classList.remove('hidden'); | |
this.targets.input.setAttribute('aria-expanded', 'true'); | |
} else { | |
this.targets.list.classList.add('hidden'); | |
this.targets.input.setAttribute('aria-expanded', 'false'); | |
} | |
} | |
renderTemplate(option) { | |
if (!this.targets.template) { | |
return option.label; | |
} | |
const template = this.targets.template.innerHTML; | |
const compiled = template.replace(/__([^_]+)__/g, (match, key) => { | |
const value = option.data[key.toLowerCase()] ?? option[key.toLowerCase()] ?? match; | |
return this.escapeHtml(String(value)); | |
}); | |
return this.highlightMatches(compiled); | |
} | |
highlightMatches(content) { | |
if (!this.search.trim()) return content; | |
const regex = new RegExp(`(${this.escapeRegex(this.search)})`, 'gi'); | |
return content.replace(regex, `<mark class="bg-yellow-200 rounded-sm p-0 m-0">$1</mark>`); | |
} | |
scrollIntoView() { | |
const activeOption = this.targets.list.querySelector(`#option-${this.activeIndex}`); | |
if (activeOption) { | |
activeOption.scrollIntoView({ block: 'nearest' }); | |
} | |
} | |
close() { | |
this.open = false; | |
this.render(); | |
} | |
// --- Utilities --- | |
searchInData(data, search) { | |
return Object.values(data).some(value => | |
String(value).toLowerCase().includes(search.toLowerCase()) | |
); | |
} | |
escapeRegex(string) { | |
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
} | |
escapeHtml(text) { | |
const div = document.createElement('div'); | |
div.textContent = text; | |
return div.innerHTML; | |
} | |
// Public API methods | |
setValue(value) { | |
const option = this.normalizedOptions.find(opt => String(opt.id) === String(value)); | |
if (option) { | |
this.selectOption(option); | |
} else { | |
this.clearSelection(); | |
} | |
} | |
getValue() { | |
return this.selectedOption ? this.selectedOption.id : null; | |
} | |
getSelectedOption() { | |
return this.selectedOption; | |
} | |
setOptions(newOptions) { | |
this.normalizedOptions = this.normalizeOptions(newOptions); | |
this.filteredOptions = [...this.normalizedOptions]; | |
// Check if current selection is still valid | |
if (this.selectedOption) { | |
const stillExists = this.normalizedOptions.find(opt => opt.id === this.selectedOption.id); | |
if (!stillExists) { | |
this.clearSelection(); | |
} | |
} | |
this.render(); | |
} | |
} | |
// Enhanced Auto-Initializer with cleanup support | |
class ComboboxManager { | |
constructor() { | |
this.instances = new Map(); | |
this.init(); | |
} | |
init() { | |
this.initializeComboboxes(); | |
// Watch for dynamically added comboboxes | |
if (window.MutationObserver) { | |
this.observer = new MutationObserver((mutations) => { | |
mutations.forEach((mutation) => { | |
mutation.addedNodes.forEach((node) => { | |
if (node.nodeType === Node.ELEMENT_NODE) { | |
// Check if the node itself is a combobox | |
if (node.matches && node.matches('[data-controller="combobox"]')) { | |
this.initializeCombobox(node); | |
} | |
// Check for comboboxes within the added node | |
const comboboxes = node.querySelectorAll && node.querySelectorAll('[data-controller="combobox"]'); | |
if (comboboxes) { | |
comboboxes.forEach(el => this.initializeCombobox(el)); | |
} | |
} | |
}); | |
mutation.removedNodes.forEach((node) => { | |
if (node.nodeType === Node.ELEMENT_NODE) { | |
// Cleanup removed comboboxes | |
if (node.matches && node.matches('[data-controller="combobox"]')) { | |
this.destroyCombobox(node); | |
} | |
const comboboxes = node.querySelectorAll && node.querySelectorAll('[data-controller="combobox"]'); | |
if (comboboxes) { | |
comboboxes.forEach(el => this.destroyCombobox(el)); | |
} | |
} | |
}); | |
}); | |
}); | |
this.observer.observe(document.body, { | |
childList: true, | |
subtree: true | |
}); | |
} | |
} | |
initializeComboboxes() { | |
const comboboxes = document.querySelectorAll('[data-controller="combobox"]'); | |
comboboxes.forEach(el => this.initializeCombobox(el)); | |
} | |
initializeCombobox(element) { | |
if (!this.instances.has(element)) { | |
const instance = new Combobox(element); | |
this.instances.set(element, instance); | |
} | |
} | |
destroyCombobox(element) { | |
if (this.instances.has(element)) { | |
const instance = this.instances.get(element); | |
instance.destroy(); | |
this.instances.delete(element); | |
} | |
} | |
getInstance(element) { | |
return this.instances.get(element); | |
} | |
getInstances(element) { | |
return this.instances; | |
} | |
destroy() { | |
this.instances.forEach((instance) => { | |
instance.destroy(); | |
}); | |
this.instances.clear(); | |
if (this.observer) { | |
this.observer.disconnect(); | |
} | |
} | |
} | |
// Initialize the manager when DOM is ready | |
if (document.readyState === 'loading') { | |
document.addEventListener('DOMContentLoaded', () => { | |
window.comboboxManager = new ComboboxManager(); | |
}); | |
} else { | |
window.comboboxManager = new ComboboxManager(); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// WARN: This is experimental and it hasn't been tested yet | |
// Please use the typeahaed.v1.js which is very stable | |
// Register the combobox directive using Livewire's directive system | |
document.addEventListener('livewire:init', () => { | |
Livewire.directive('combobox', ({ el, directive, component, cleanup }) => { | |
// Initialize the combobox on this element | |
const combobox = new LivewireCombobox(el, component); | |
// Register cleanup for when component is removed | |
cleanup(() => { | |
combobox.destroy(); | |
}); | |
}); | |
}); | |
class LivewireCombobox { | |
constructor(element, component) { | |
this.element = element; | |
this.component = component; | |
this.$wire = component.$wire; | |
// Parse configuration from data attributes | |
this.config = { | |
options: JSON.parse(element.dataset.options || '[]'), | |
selected: JSON.parse(element.dataset.selected || 'null'), | |
name: element.dataset.name || 'value' | |
}; | |
// Find all target elements within the component's scope | |
this.targets = { | |
wrapper: this.element.querySelector('[data-combobox-target="wrapper"]'), | |
input: this.element.querySelector('[data-combobox-target="input"]'), | |
list: this.element.querySelector('[data-combobox-target="list"]'), | |
hiddenInput: this.element.querySelector('[data-combobox-target="hiddenInput"]'), | |
template: this.element.querySelector('[data-combobox-target="template"]'), | |
empty: this.element.querySelector('[data-combobox-target="empty"]'), | |
}; | |
// Validate required targets exist | |
if (!this.targets.input || !this.targets.list || !this.targets.hiddenInput) { | |
console.error('Combobox: Required targets not found'); | |
return; | |
} | |
// State | |
this.open = false; | |
this.search = ''; | |
this.activeIndex = -1; | |
this.selectedOption = null; | |
this.isInitializing = true; | |
// Normalize options once at the beginning | |
this.normalizedOptions = this.normalizeOptions(this.config.options); | |
this.filteredOptions = [...this.normalizedOptions]; | |
this.init(); | |
} | |
// --- Initialization --- | |
init() { | |
this.bindEvents(); | |
if (this.config.selected) { | |
const initialOption = this.normalizedOptions.find(opt => | |
String(opt.id) === String(this.config.selected) | |
); | |
if (initialOption) { | |
this.selectOption(initialOption, false); | |
} | |
} | |
this.isInitializing = false; | |
} | |
bindEvents() { | |
// Bind events with proper context | |
this.boundFocusOnInput = this.focusOnInput.bind(this); | |
this.boundOnFocus = this.onFocus.bind(this); | |
this.boundOnBlur = this.onBlur.bind(this); | |
this.boundOnKeyDown = this.onKeyDown.bind(this); | |
this.boundOnSearch = this.onSearch.bind(this); | |
this.boundOnOptionClick = this.onOptionClick.bind(this); | |
this.targets.wrapper.addEventListener('click', this.boundFocusOnInput); | |
this.targets.input.addEventListener('focus', this.boundOnFocus); | |
this.targets.input.addEventListener('blur', this.boundOnBlur); | |
this.targets.input.addEventListener('keydown', this.boundOnKeyDown); | |
this.targets.input.addEventListener('input', this.boundOnSearch); | |
this.targets.list.addEventListener('mousedown', this.boundOnOptionClick); | |
} | |
destroy() { | |
if (this.targets.wrapper) { | |
this.targets.wrapper.removeEventListener('click', this.boundFocusOnInput); | |
} | |
if (this.targets.input) { | |
this.targets.input.removeEventListener('focus', this.boundOnFocus); | |
this.targets.input.removeEventListener('blur', this.boundOnBlur); | |
this.targets.input.removeEventListener('keydown', this.boundOnKeyDown); | |
this.targets.input.removeEventListener('input', this.boundOnSearch); | |
} | |
if (this.targets.list) { | |
this.targets.list.removeEventListener('mousedown', this.boundOnOptionClick); | |
} | |
} | |
// --- State & Data Management --- | |
normalizeOptions(options) { | |
return options.map(opt => { | |
if (Array.isArray(opt)) { | |
return { id: opt[1] ?? opt[0], label: opt[0], data: opt[2] ?? {} }; | |
} | |
if (typeof opt === 'string') { | |
return { id: opt, label: opt, data: {} }; | |
} | |
if (typeof opt === 'object' && opt !== null) { | |
const { id, label, ...data } = opt; | |
return { | |
id: id ?? opt.value ?? label, | |
label: label ?? opt.name ?? opt.text ?? id, | |
data | |
}; | |
} | |
return { id: String(opt), label: String(opt), data: {} }; | |
}); | |
} | |
selectOption(option, dispatchEvent = true) { | |
if (!option) { | |
return; | |
} | |
this.selectedOption = option; | |
this.search = option.label; | |
this.targets.input.value = option.label; | |
this.targets.hiddenInput.value = option.id; | |
this.close(); | |
if (dispatchEvent && !this.isInitializing) { | |
// Update Livewire property directly using $wire | |
this.$wire.$set(this.config.name, option.id); | |
// Dispatch custom event for additional handling | |
this.element.dispatchEvent(new CustomEvent('combobox-change', { | |
bubbles: true, | |
detail: { id: option.id, label: option.label, data: option.data } | |
})); | |
} | |
} | |
clearSelection(dispatchEvent = true) { | |
this.selectedOption = null; | |
this.search = ''; | |
this.targets.input.value = ''; | |
this.targets.hiddenInput.value = ''; | |
this.filteredOptions = [...this.normalizedOptions]; | |
this.activeIndex = -1; | |
if (dispatchEvent && !this.isInitializing) { | |
// Update Livewire property | |
this.$wire.$set(this.config.name, null); | |
this.element.dispatchEvent(new CustomEvent('combobox-change', { | |
bubbles: true, | |
detail: { id: null, label: '', data: {} } | |
})); | |
} | |
this.render(); | |
} | |
// --- Event Handlers --- | |
onSearch(e) { | |
this.search = e.target.value; | |
this.activeIndex = -1; | |
if (!this.search.trim()) { | |
this.filteredOptions = [...this.normalizedOptions]; | |
if (this.selectedOption && this.selectedOption.label !== this.search) { | |
this.selectedOption = null; | |
} | |
this.open = true; | |
} else { | |
this.filteredOptions = this.normalizedOptions.filter(option => | |
option.label.toLowerCase().includes(this.search.toLowerCase()) || | |
this.searchInData(option.data, this.search) | |
); | |
this.open = true; | |
if (this.filteredOptions.length > 0 && | |
(!this.selectedOption || !this.selectedOption.label.toLowerCase().includes(this.search.toLowerCase()))) { | |
this.activeIndex = 0; | |
} | |
} | |
this.render(); | |
} | |
onOptionClick(e) { | |
e.preventDefault(); | |
const optionEl = e.target.closest('[role="option"]'); | |
if (optionEl && optionEl.dataset.index !== undefined) { | |
const index = parseInt(optionEl.dataset.index, 10); | |
if (index >= 0 && index < this.filteredOptions.length) { | |
this.selectOption(this.filteredOptions[index]); | |
} | |
} | |
} | |
focusOnInput(e) { | |
if (!this.targets.list.contains(e.target)) { | |
this.targets.input.focus(); | |
} | |
} | |
onFocus() { | |
this.open = true; | |
if (!this.selectedOption) { | |
this.search = ''; | |
this.targets.input.value = ''; | |
this.filteredOptions = [...this.normalizedOptions]; | |
this.activeIndex = -1; | |
} | |
this.render(); | |
} | |
onBlur(e) { | |
setTimeout(() => { | |
if (!this.element.contains(document.activeElement)) { | |
this.close(); | |
if (!this.selectedOption && this.search.trim()) { | |
const exactMatch = this.normalizedOptions.find(opt => | |
opt.label.toLowerCase() === this.search.toLowerCase() | |
); | |
if (exactMatch) { | |
this.selectOption(exactMatch); | |
} else { | |
this.targets.input.value = this.selectedOption ? this.selectedOption.label : ''; | |
this.search = this.selectedOption ? this.selectedOption.label : ''; | |
} | |
} else if (this.selectedOption) { | |
this.targets.input.value = this.selectedOption.label; | |
this.search = this.selectedOption.label; | |
} | |
} | |
}, 150); | |
} | |
onKeyDown(e) { | |
switch (e.key) { | |
case 'ArrowDown': | |
e.preventDefault(); | |
if (!this.open) { | |
this.open = true; | |
this.render(); | |
} else if (this.activeIndex < this.filteredOptions.length - 1) { | |
this.activeIndex++; | |
this.render(); | |
this.scrollIntoView(); | |
} | |
break; | |
case 'ArrowUp': | |
e.preventDefault(); | |
if (!this.open) { | |
this.open = true; | |
this.render(); | |
} else if (this.activeIndex > 0) { | |
this.activeIndex--; | |
this.render(); | |
this.scrollIntoView(); | |
} | |
break; | |
case 'Enter': | |
if (this.open && this.activeIndex >= 0 && this.filteredOptions[this.activeIndex]) { | |
e.preventDefault(); | |
this.selectOption(this.filteredOptions[this.activeIndex]); | |
} | |
break; | |
case 'Escape': | |
this.close(); | |
this.targets.input.blur(); | |
break; | |
case 'Tab': | |
if (this.open) { | |
this.close(); | |
} | |
break; | |
} | |
} | |
// --- Rendering & DOM Manipulation --- | |
render() { | |
if (!this.targets.list) return; | |
this.targets.list.innerHTML = ''; | |
if (this.filteredOptions.length < 1) { | |
const noResults = document.createElement('div'); | |
noResults.innerHTML = this.targets.empty ? this.targets.empty.innerHTML : '<p class="p-4 text-sm text-gray-500 text-center">No results found</p>'; | |
this.targets.list.appendChild(noResults); | |
} else { | |
this.filteredOptions.forEach((option, index) => { | |
const optionEl = document.createElement('div'); | |
optionEl.role = 'option'; | |
optionEl.dataset.index = index; | |
optionEl.id = `option-${index}`; | |
optionEl.className = 'cursor-pointer select-none relative py-2 pl-3 pr-9'; | |
optionEl.setAttribute('aria-selected', this.activeIndex === index ? 'true' : 'false'); | |
if (this.activeIndex === index) { | |
optionEl.classList.add('bg-secondary', 'text-primary', 'font-semibold'); | |
} else { | |
optionEl.classList.add('text-gray-300'); | |
} | |
optionEl.innerHTML = this.renderTemplate(option); | |
if (this.selectedOption?.id === option.id) { | |
const checkIcon = ` | |
<span class="absolute inset-y-0 right-0 flex items-center pr-4 ${this.activeIndex === index ? 'text-white' : ''}"> | |
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> | |
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> | |
</svg> | |
</span>`; | |
optionEl.insertAdjacentHTML('beforeend', checkIcon); | |
} | |
this.targets.list.appendChild(optionEl); | |
}); | |
} | |
if (this.open) { | |
this.targets.list.classList.remove('hidden'); | |
this.targets.input.setAttribute('aria-expanded', 'true'); | |
} else { | |
this.targets.list.classList.add('hidden'); | |
this.targets.input.setAttribute('aria-expanded', 'false'); | |
} | |
} | |
renderTemplate(option) { | |
if (!this.targets.template) { | |
return option.label; | |
} | |
const template = this.targets.template.innerHTML; | |
const compiled = template.replace(/__([^_]+)__/g, (match, key) => { | |
const value = option.data[key.toLowerCase()] ?? option[key.toLowerCase()] ?? match; | |
return this.escapeHtml(String(value)); | |
}); | |
return this.highlightMatches(compiled); | |
} | |
highlightMatches(content) { | |
if (!this.search.trim()) return content; | |
const regex = new RegExp(`(${this.escapeRegex(this.search)})`, 'gi'); | |
return content.replace(regex, `<mark class="bg-yellow-200 rounded-sm p-0 m-0">$1</mark>`); | |
} | |
scrollIntoView() { | |
const activeOption = this.targets.list.querySelector(`#option-${this.activeIndex}`); | |
if (activeOption) { | |
activeOption.scrollIntoView({ block: 'nearest' }); | |
} | |
} | |
close() { | |
this.open = false; | |
this.render(); | |
} | |
// --- Utilities --- | |
searchInData(data, search) { | |
return Object.values(data).some(value => | |
String(value).toLowerCase().includes(search.toLowerCase()) | |
); | |
} | |
escapeRegex(string) { | |
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
} | |
escapeHtml(text) { | |
const div = document.createElement('div'); | |
div.textContent = text; | |
return div.innerHTML; | |
} | |
// Public API methods accessible via $wire | |
setValue(value) { | |
const option = this.normalizedOptions.find(opt => String(opt.id) === String(value)); | |
if (option) { | |
this.selectOption(option); | |
} else { | |
this.clearSelection(); | |
} | |
} | |
getValue() { | |
return this.selectedOption ? this.selectedOption.id : null; | |
} | |
getSelectedOption() { | |
return this.selectedOption; | |
} | |
setOptions(newOptions) { | |
this.normalizedOptions = this.normalizeOptions(newOptions); | |
this.filteredOptions = [...this.normalizedOptions]; | |
if (this.selectedOption) { | |
const stillExists = this.normalizedOptions.find(opt => opt.id === this.selectedOption.id); | |
if (!stillExists) { | |
this.clearSelection(); | |
} | |
} | |
this.render(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Credits/Contributors: