Files
ACORE/static/assets/js/searchable-select.js
T
lokses ae54d93442 core: секции портала, контроль доступа и вью страниц
Section-based portal access, matrix прав по группам, аккаунтинг-портал,
управление группами пользователей, справочники, дашборд и общий каркас
навигации/стилей.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:20 +03:00

202 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.
/**
* Одиночний 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, '');
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);
}).slice(0, 50);
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);
});
};
})();