Files
ACORE/static/assets/js/searchable-select.js
T
lokses 777de2adea it_manager: сортировка локаций по коду и полный список в селекте.
Аптеки А1…А70/П… вместо сортировки по адресу; убрать лимит 50 в searchable-select.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:39:56 +03:00

204 lines
8.2 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.
/**
* Одиночний select із пошуком по введенню тексту — заміна звичайного
* <select> з довгим списком опцій (локації, тощо), де потрібно швидко
* знайти запис, а не скролити список.
*
* Розмітка:
* <div class="searchable-select" data-placeholder="Пошук...">
* <select name="location_id" hidden>
* <option value="">—</option>
* <option value="1">Аптека №23 — вул. ...</option>
* </select>
* </div>
*
* Використання: initSearchableSelects(scopeEl) після вставки розмітки
* в DOM (в т.ч. після HTMX-свапу фрагмента).
*/
(function () {
function options(select) {
return Array.prototype.slice.call(select.options).filter(function (o) { return o.value; });
}
function currentLabel(select) {
var opt = select.options[select.selectedIndex];
return opt && opt.value ? opt.textContent : '';
}
/**
* Латиниця ↔ кирилиця для кодів аптек: a/а, p/п, t/т, g/г, l/л.
* Нормалізуємо і запит, і текст опції в один алфавіт (кирилиця).
*/
var LOOKALIKE_TO_CYR = {
a: 'а', A: 'а',
p: 'п', P: 'п',
t: 'т', T: 'т',
g: 'г', G: 'г',
l: 'л', L: 'л',
а: 'а', А: 'а',
п: 'п', П: 'п',
т: 'т', Т: 'т',
г: 'г', Г: 'г',
л: 'л', Л: 'л'
};
function foldLookalikes(text) {
return String(text || '').replace(/[aApPtTgGlLаАпПтТгГлЛ]/g, function (ch) {
return LOOKALIKE_TO_CYR[ch] || ch;
}).toLowerCase();
}
function optionHaystack(opt) {
var extra = opt.getAttribute('data-search') || '';
return foldLookalikes(opt.textContent + ' ' + extra);
}
function renderResults(widget, query) {
var select = widget._select;
var results = widget._results;
var q = foldLookalikes(query.trim());
// також без пробілів: «а 3» → «а3»
var qCompact = q.replace(/\s+/g, '');
// Без лимита: иначе при пустом запросе видны только первые ~50 опций
// (аптеки «ниже по списку» пропадали). Скролл в dropdown справляется.
var matches = options(select).filter(function (o) {
if (!q) return true;
var hay = optionHaystack(o);
var hayCompact = hay.replace(/\s+/g, '');
return hay.indexOf(q) !== -1 || (qCompact && hayCompact.indexOf(qCompact) !== -1);
});
results.innerHTML = '';
matches.forEach(function (opt) {
var row = document.createElement('div');
row.className = 'people-picker-row';
if (opt.value === select.value) row.classList.add('is-selected');
var info = document.createElement('div');
info.className = 'people-picker-row-info';
var name = document.createElement('div');
name.className = 'people-picker-row-name';
name.textContent = opt.textContent;
info.appendChild(name);
row.appendChild(info);
row.addEventListener('mousedown', function (e) {
e.preventDefault();
select.value = opt.value;
select.dispatchEvent(new Event('change'));
widget._search.value = opt.textContent;
widget._dropdown.style.display = 'none';
updateClearButton(widget);
});
results.appendChild(row);
});
if (!matches.length) {
var empty = document.createElement('div');
empty.className = 'people-picker-row';
empty.style.cursor = 'default';
empty.style.color = 'var(--color-muted-foreground)';
empty.textContent = 'Нічого не знайдено';
results.appendChild(empty);
}
}
function updateClearButton(widget) {
widget._clear.style.display = widget._select.value ? 'flex' : 'none';
}
function build(widget) {
var select = widget.querySelector('select');
if (!select) return;
widget._select = select;
var searchWrap = document.createElement('div');
searchWrap.style.position = 'relative';
var search = document.createElement('input');
search.type = 'text';
search.className = 'input searchable-select-search';
search.placeholder = widget.dataset.placeholder || 'Пошук...';
search.value = currentLabel(select);
search.autocomplete = 'off';
// required на hidden-select браузер ігнорує (barred from constraint
// validation) — переносимо на видимий інпут, щоб підказка лишилась
if (select.required) search.required = true;
widget._search = search;
var clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'searchable-select-clear';
clearBtn.innerHTML = '&times;';
clearBtn.style.display = select.value ? 'flex' : 'none';
clearBtn.setAttribute('aria-label', 'Очистити');
clearBtn.addEventListener('mousedown', function (e) {
e.preventDefault();
select.value = '';
select.dispatchEvent(new Event('change'));
search.value = '';
clearBtn.style.display = 'none';
search.focus();
});
widget._clear = clearBtn;
var dropdown = document.createElement('div');
dropdown.className = 'people-picker-dropdown';
dropdown.style.display = 'none';
widget._dropdown = dropdown;
var results = document.createElement('div');
results.className = 'people-picker-results';
dropdown.appendChild(results);
widget._results = results;
function open() {
dropdown.style.display = 'block';
renderResults(widget, search.value === currentLabel(select) ? '' : search.value);
}
search.addEventListener('focus', function () {
search.select();
open();
});
search.addEventListener('input', function () {
renderResults(widget, search.value);
dropdown.style.display = 'block';
});
search.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
dropdown.style.display = 'none';
search.value = currentLabel(select);
search.blur();
return;
}
// Enter у полі пошуку не повинен сабмітити форму — обираємо перший результат
if (e.key === 'Enter') {
e.preventDefault();
var first = results.querySelector('.people-picker-row:not([style*="cursor: default"])');
if (first) first.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
}
});
search.addEventListener('blur', function () {
// Якщо користувач не обрав опцію — повертаємо текст обраного значення
setTimeout(function () {
if (dropdown.style.display === 'none') search.value = currentLabel(select);
}, 150);
});
document.addEventListener('click', function (e) {
if (!widget.contains(e.target)) {
dropdown.style.display = 'none';
search.value = currentLabel(select);
}
});
searchWrap.appendChild(search);
searchWrap.appendChild(clearBtn);
searchWrap.appendChild(dropdown);
widget.appendChild(searchWrap);
}
window.initSearchableSelects = function (scope) {
(scope || document).querySelectorAll('.searchable-select:not([data-searchable-init])').forEach(function (widget) {
widget.setAttribute('data-searchable-init', '1');
build(widget);
});
};
})();