Skip to content

Instantly share code, notes, and snippets.

@Parables
Created July 3, 2025 01:07
Show Gist options
  • Save Parables/a9826ecbfa4240470fff0c28b38555c8 to your computer and use it in GitHub Desktop.
Save Parables/a9826ecbfa4240470fff0c28b38555c8 to your computer and use it in GitHub Desktop.
Laravel Blade Typeahaed/Combobox with Pure JS - Zero dependencies
@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>
// 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));
// 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();
}
// 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();
}
}
@Parables
Copy link
Author

Parables commented Jul 3, 2025

Credits/Contributors:

  • Parables Boltnoel: Initial basic version with JS
  • Google Gemini: Ported the Alpine version into a reusable JS version(typeahead.v0.js)
  • Claude.ai: Created the Alpine version, Improved Gemini's reusable JS version (typeahead.v0.js => typeahead.v1.js && typeahead.v2.js)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment