Много разных изменений
This commit is contained in:
193
frontend/admin/js/async-combobox.js
Normal file
193
frontend/admin/js/async-combobox.js
Normal file
@@ -0,0 +1,193 @@
|
||||
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("'", ''');
|
||||
}
|
||||
Reference in New Issue
Block a user