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("'", ''');
}