194 lines
8.0 KiB
JavaScript
194 lines
8.0 KiB
JavaScript
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 = `
|
||
<div class="async-combobox-control">
|
||
<input type="search" role="combobox" aria-autocomplete="list" aria-expanded="false"
|
||
autocomplete="off" placeholder="${escapeText(placeholder)}">
|
||
<button type="button" class="async-combobox-clear" aria-label="Очистить выбор" ${allowEmpty ? '' : 'hidden'}>×</button>
|
||
</div>
|
||
<div class="async-combobox-menu" role="listbox" hidden></div>
|
||
`;
|
||
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 = '<div class="async-combobox-state"><span class="state-spinner" aria-hidden="true"></span>Поиск...</div>';
|
||
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 = `<div class="async-combobox-state is-error">${escapeText(error.message || 'Не удалось выполнить поиск')}</div>`;
|
||
}
|
||
}
|
||
|
||
renderItems() {
|
||
if (!this.items.length) {
|
||
this.menu.innerHTML = `<div class="async-combobox-state">${escapeText(this.emptyLabel)}</div>`;
|
||
return;
|
||
}
|
||
this.menu.innerHTML = this.items.map((item, index) => {
|
||
const value = String(this.optionValue(item));
|
||
const description = this.optionDescription(item);
|
||
return `<button type="button" role="option" class="async-combobox-option" data-index="${index}"
|
||
aria-selected="${String(this.select.value) === value}">
|
||
<strong>${escapeText(this.optionLabel(item))}</strong>
|
||
${description ? `<small>${escapeText(description)}</small>` : ''}
|
||
</button>`;
|
||
}).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 = `<option value="${escapeText(value)}" selected>${escapeText(label)}</option>`;
|
||
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 = '<option value="" selected></option>';
|
||
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("'", ''');
|
||
}
|