const DEFAULTS = { threshold: 2, maximumItems: 5, highlightTyped: true, highlightClass: 'text-primary', inputLabel: 'label', dropdownLabel: 'label', value: 'value', showValue: false, showValueBeforeLabel: false, remoteData: null, remoteDataHttpMethod: 'GET', data: [], resolveData: (response) => response, onInput: null, onSelectItem: null, multiSelect: false, dropdownClass: '', selectedItems: [] }; class BootstrapSearch { constructor(field, options) { this.field = field; this.options = Object.assign({}, DEFAULTS, options); this.dropdown = null; this.controller = null; this.activeIndex = -1; this.selectedItems = (this.options.selectedItems || []).map(item => ({ value: typeof this.options.value === 'function' ? this.options.value(item) : typeof this.options.value === 'string' ? item[this.options.value] ?? '' : item.value ?? '', label: typeof this.options.inputLabel === 'function' ? this.options.inputLabel(item) : typeof this.options.inputLabel === 'string' ? item[this.options.inputLabel] ?? '' : item.label ?? '' })); if(this.options.data.length === 0) this.options.data = [...this.options.selectedItems]; this._updatingValue = false; field.classList.add('bootstrap-search-field', 'form-control'); const wrapper = document.createElement('div'); wrapper.className = 'position-relative'; field.parentNode.insertBefore(wrapper, field); wrapper.appendChild(field); this.statusIcon = document.createElement('span'); this.statusIcon.className = 'position-absolute top-50 end-0 translate-middle-y pe-2'; wrapper.appendChild(this.statusIcon); if(this.selectedItems.length > 0 || this.field.value) { this.showSuccess(); } else{ this.setDefaultIcon(); } const dropdownDiv = document.createElement('div'); dropdownDiv.className = 'dropdown-menu w-100'; dropdownDiv.style.maxHeight = '250px'; dropdownDiv.style.overflowY = 'auto'; if (this.options.dropdownClass) dropdownDiv.classList.add(this.options.dropdownClass); wrapper.appendChild(dropdownDiv); this.dropdownDiv = dropdownDiv; this.dropdown = new bootstrap.Dropdown(field, { autoClose: !this.options.multiSelect }); this.field.addEventListener('input', () => { if (this._updatingValue) return; this.activeIndex = -1; if (this.options.multiSelect) { const currentValues = this.field.value.split(',').map(v => v.trim()); this.selectedItems = this.selectedItems.filter(si => currentValues.includes(si.label)); this.options.onSelectItem && this.options.onSelectItem([...this.selectedItems]); this.options.onSelectItem && this.options.onSelectItem([]); } else{ this.selectedItems = []; this.options.onSelectItem && this.options.onSelectItem(null); } this.clearStatus(); this.setDefaultIcon(); this.dropdownDiv.querySelectorAll('.dropdown-item i.fas.fa-check').forEach(icon => icon.remove()); if (this.options.onInput) this.options.onInput(this.field.value); if (this.options.remoteData) { if (this.field.value.length >= this.options.threshold) this.showLoading(); this.fetchData(this.field.value).then(_=>this.renderIfNeeded()); } else { this.renderIfNeeded(); } }); field.addEventListener('keydown', (e) => this.handleKeydown(e)); document.addEventListener('click', (e) => { if (!this.dropdownDiv.contains(e.target) && e.target !== this.field) { this.dropdown.hide(); } }); field.addEventListener('focus', () => { if (this.options.data && this.options.data.length) { this.renderIfNeeded(); this.dropdown.show(); } }); if (this.selectedItems.length > 0) { this._updatingValue = true; this.field.value = this.options.multiSelect ? this.selectedItems.map(si => si.label).join(', ') : this.selectedItems[0].label; this._updatingValue = false; } field.bootstrapSearch = this; } clear(){ this.selectedItems = []; this.field.value = '' this.setDefaultIcon(); this.dropdown.hide(); } handleKeydown(e) { const items = Array.from(this.dropdownDiv.querySelectorAll('.dropdown-item')); if (!items.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); this.activeIndex = (this.activeIndex + 1) % items.length; this.updateActive(items); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.activeIndex = (this.activeIndex - 1 + items.length) % items.length; this.updateActive(items); } else if (e.key === 'Enter') { e.preventDefault(); if (this.activeIndex >= 0 && items[this.activeIndex]) items[this.activeIndex].click(); } else if (e.key === 'Escape') { this.dropdown.hide(); } } updateActive(items) { items.forEach((item, i) => { if (i === this.activeIndex) { item.classList.add('active'); item.scrollIntoView({ block: 'nearest' }); } else { item.classList.remove('active'); } }); } setDefaultIcon() { this.statusIcon.innerHTML = ``; } showLoading() { this.statusIcon.innerHTML = `
Loading...
`; } showSuccess() { this.statusIcon.innerHTML = ``; } showNoResults() { this.statusIcon.innerHTML = ``; } showError() { this.statusIcon.innerHTML = ``; } clearStatus() { this.statusIcon.innerHTML = ''; } async fetchData(query) { if (this.options.multiSelect) { const parts = query.split(','); query = parts[parts.length - 1].trim(); } if (query.length < this.options.threshold) { this.clearStatus(); this.setDefaultIcon(); return; } if (this.controller) this.controller.abort(); this.controller = new AbortController(); try { const url = typeof this.options.remoteData === 'function' ? this.options.remoteData(encodeURIComponent(query)) : this.options.remoteData; const method = (this.options.remoteDataHttpMethod || 'GET').toUpperCase(); let fetchOptions = { method, signal: this.controller.signal }; if (method === 'POST') { let formData = new FormData(); const form = this.field.closest('form'); if (form) { new FormData(form).forEach((v, k) => formData.append(k, v)); } formData.append('q', query); fetchOptions.body = formData; } const response = await fetch(url, fetchOptions); const data = await response.json(); this.setData(this.options.resolveData(data)); } catch (err) { if (err.name !== 'AbortError') { console.error(err); this.showError(); } } finally { if (!this.selectedItems.length) this.setDefaultIcon(); } } setData(data) { this.options.data = data; this.renderIfNeeded(); } renderIfNeeded() { const count = this.createItems(); if (count > 0) { this.dropdown.show(); if(this.selectedItems.length == 0){ this.setDefaultIcon(); } } else { this.dropdown.hide(); this.showNoResults(); } } createItem(lookup, item) { function escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } let labelHtml; let itemLabel; if (typeof this.options.dropdownLabel === 'function') { itemLabel = this.options.dropdownLabel(item); } else { if (typeof this.options.dropdownLabel === 'string') { itemLabel = escapeHtml(item[this.options.dropdownLabel] ?? ''); } else { itemLabel = escapeHtml(item.label ?? ''); } } if (this.options.highlightTyped && lookup) { const plainLabel = removeDiacritics(itemLabel).toLowerCase(); const search = removeDiacritics(lookup).toLowerCase(); const idx = plainLabel.indexOf(search); const className = Array.isArray(this.options.highlightClass) ? this.options.highlightClass.join(' ') : this.options.highlightClass; if (idx >= 0 && typeof this.options.dropdownLabel !== 'function') { labelHtml = itemLabel.substring(0, idx) + `${escapeHtml(itemLabel.substring(idx, idx + lookup.length))}` + escapeHtml(itemLabel.substring(idx + lookup.length)); } else { labelHtml = itemLabel; } } else { labelHtml = itemLabel; } if (typeof this.options.dropdownLabel !== 'function') { labelHtml = `
${labelHtml}
`; } if (this.options.showValue) { let val; if (typeof this.options.value === 'function') { val = this.options.value(item); } else if (typeof this.options.value === 'string') { val = item[this.options.value] ?? ''; } else { val = item.value ?? ''; } const safeVal = escapeHtml(val); if (this.options.showValueBeforeLabel) { labelHtml = `${safeVal} ${labelHtml}`; } else { labelHtml = `${labelHtml} ${safeVal}`; } } let dataValue; if (typeof this.options.value === 'function') { dataValue = this.options.value(item); } else if (typeof this.options.value === 'string') { dataValue = item[this.options.value] ?? ''; } else { dataValue = item.value ?? ''; } const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'dropdown-item d-flex justify-content-between align-items-center'; btn.setAttribute('data-label', escapeHtml(typeof this.options.inputLabel === 'function' ? this.options.inputLabel(item) : item[this.options.inputLabel] ?? '')); btn.setAttribute('data-value', dataValue); btn.innerHTML = labelHtml; if (this.selectedItems.find(si => si.value == dataValue)) { btn.innerHTML += ' '; } return btn; } createItems() { const dropdownDiv = this.dropdownDiv; dropdownDiv.innerHTML = ''; if (!this.options.data) return 0; const dataArray = Array.isArray(this.options.data) ? this.options.data : Object.values(this.options.data); let count = 0; for (let entry of dataArray) { if (this.options.multiSelect) { dropdownDiv.appendChild(this.createItem(null, entry)); } else { const lookup = this.field.value; let itemLabel; if (typeof this.options.dropdownLabel === 'function') { itemLabel = this.options.dropdownLabel(entry); } else { if (typeof this.options.dropdownLabel === 'string') { if (entry[this.options.dropdownLabel] !== undefined && entry[this.options.dropdownLabel] !== null) { itemLabel = entry[this.options.dropdownLabel]; } else { itemLabel = ''; } } else { if (entry.label !== undefined && entry.label !== null) { itemLabel = entry.label; } else { itemLabel = ''; } } } if (removeDiacritics(itemLabel).toLowerCase().includes(removeDiacritics(lookup).toLowerCase())) { dropdownDiv.appendChild(this.createItem(lookup, entry)); if (this.options.maximumItems > 0 && ++count >= this.options.maximumItems) break; } } } const items = dropdownDiv.querySelectorAll('.dropdown-item'); items.forEach(itemEl => { itemEl.addEventListener('click', e => this.onItemSelected(e)); }); if (items.length > 0){ this.dropdown.show(); } else { this.dropdown.hide(); } return items.length; } onItemSelected(e){ const dataLabel = e.currentTarget.getAttribute('data-label'); const dataValue = e.currentTarget.getAttribute('data-value'); if (this.options.multiSelect) { const exists = this.selectedItems.find(si => si.value == dataValue); if (!exists) { this.selectedItems.push({ value: dataValue, label: dataLabel }); } else { this.selectedItems = this.selectedItems.filter(si => si.value != dataValue); } this._updatingValue = true; this.field.value = this.selectedItems.map(si => si.label).join(', '); this._updatingValue = false; this.renderIfNeeded(); if (this.selectedItems.length){ this.showSuccess(); } else { this.setDefaultIcon(); } if (this.options.onSelectItem){ this.options.onSelectItem([...this.selectedItems]); } } else { this.selectedItems = [{ value: dataValue, label: dataLabel }]; this.field.value = dataLabel; this.dropdown.hide(); this.showSuccess(); if (this.options.onSelectItem){ this.options.onSelectItem(this.selectedItems[0]); } } } } function removeDiacritics(str) { return str?.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); }