import { debounce } from './url-state.js'; export class AsyncCombobox { constructor(select, { load, optionValue = item => item.id, optionLabel = item => item.name, optionDescription = () => '', placeholder = 'Начните вводить название', emptyLabel = 'Ничего не найдено', allowEmpty = true }) { if (!select || typeof load !== 'function') throw new Error('Для поискового списка нужны select и функция загрузки'); this.select = select; this.load = load; this.optionValue = optionValue; this.optionLabel = optionLabel; this.optionDescription = optionDescription; this.emptyLabel = emptyLabel; this.allowEmpty = allowEmpty; this.requestNumber = 0; this.items = []; select.dataset.nativeSelect = 'true'; select.hidden = true; this.root = document.createElement('div'); this.root.className = 'async-combobox'; this.root.innerHTML = `
`; select.insertAdjacentElement('afterend', this.root); this.input = this.root.querySelector('input'); this.menu = this.root.querySelector('[role="listbox"]'); this.clearButton = this.root.querySelector('.async-combobox-clear'); this.input.setAttribute('aria-label', select.getAttribute('aria-label') || placeholder); this.labels = select.id ? [...document.querySelectorAll('label[for]')].filter(label => label.htmlFor === select.id) : []; if (select.id) { this.input.id = `${select.id}-search`; this.labels.forEach(label => { label.htmlFor = this.input.id; }); } const listId = `${select.id || 'async-combobox'}-listbox`; this.menu.id = listId; this.input.setAttribute('aria-controls', listId); this.input.addEventListener('focus', () => this.search(this.input.value)); this.input.addEventListener('input', debounce(() => this.search(this.input.value), 320)); this.input.addEventListener('keydown', event => this.handleKeydown(event)); this.clearButton.addEventListener('click', () => this.clear()); this.menu.addEventListener('mousedown', event => event.preventDefault()); this.menu.addEventListener('keydown', event => this.handleMenuKeydown(event)); this.handleOutsideClick = event => { if (!this.root.contains(event.target)) this.close(); }; document.addEventListener('click', this.handleOutsideClick); this.syncFromSelect(); } async search(query = '') { return this.loadMatches(query, true); } async preload(query = '') { return this.loadMatches(query, false); } async loadMatches(query, revealMenu) { const requestNumber = ++this.requestNumber; if (revealMenu) this.open(); this.menu.innerHTML = '
Поиск...
'; try { const items = await this.load(query.trim(), this.select.value || ''); if (requestNumber !== this.requestNumber) return; this.items = Array.isArray(items) ? items : []; this.renderItems(); } catch (error) { if (requestNumber !== this.requestNumber) return; this.menu.innerHTML = `
${escapeText(error.message || 'Не удалось выполнить поиск')}
`; } } renderItems() { if (!this.items.length) { this.menu.innerHTML = `
${escapeText(this.emptyLabel)}
`; return; } this.menu.innerHTML = this.items.map((item, index) => { const value = String(this.optionValue(item)); const description = this.optionDescription(item); return ``; }).join(''); this.menu.querySelectorAll('[data-index]').forEach(button => { button.addEventListener('click', () => this.choose(this.items[Number(button.dataset.index)])); }); } choose(item) { const value = String(this.optionValue(item)); const label = this.optionLabel(item); this.select.innerHTML = ``; this.select.value = value; this.input.value = label; this.input.dataset.selectedValue = value; this.select.dispatchEvent(new Event('change', { bubbles: true })); this.close(); } clear() { this.select.innerHTML = ''; this.select.value = ''; this.input.value = ''; delete this.input.dataset.selectedValue; this.select.dispatchEvent(new Event('change', { bubbles: true })); this.input.focus(); this.search(''); } syncFromSelect() { const selected = this.select.selectedOptions?.[0]; this.input.value = selected?.value ? selected.textContent.trim() : ''; if (selected?.value) this.input.dataset.selectedValue = selected.value; } open() { this.menu.hidden = false; this.input.setAttribute('aria-expanded', 'true'); this.root.classList.add('is-open'); } close() { this.menu.hidden = true; this.input.setAttribute('aria-expanded', 'false'); this.root.classList.remove('is-open'); this.syncFromSelect(); } handleKeydown(event) { const options = [...this.menu.querySelectorAll('[role="option"]')]; const current = options.indexOf(document.activeElement); if (event.key === 'Escape') { this.close(); } else if (event.key === 'ArrowDown' && options.length) { event.preventDefault(); options[Math.min(current + 1, options.length - 1)].focus(); } else if (event.key === 'ArrowUp' && options.length) { event.preventDefault(); options[Math.max(current - 1, 0)].focus(); } } handleMenuKeydown(event) { const options = [...this.menu.querySelectorAll('[role="option"]')]; const current = options.indexOf(document.activeElement); if (event.key === 'Escape') { event.preventDefault(); this.close(); this.input.focus(); } else if (event.key === 'ArrowDown' && current >= 0) { event.preventDefault(); options[Math.min(current + 1, options.length - 1)]?.focus(); } else if (event.key === 'ArrowUp' && current >= 0) { event.preventDefault(); (current === 0 ? this.input : options[current - 1])?.focus(); } } destroy() { document.removeEventListener('click', this.handleOutsideClick); this.requestNumber += 1; this.root.remove(); this.select.hidden = false; this.labels.forEach(label => { label.htmlFor = this.select.id; }); } } function escapeText(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); }