Files
magistr/frontend/admin/js/async-combobox.js
2026-08-20 00:55:27 +03:00

194 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}