777de2adea
Аптеки А1…А70/П… вместо сортировки по адресу; убрать лимит 50 в searchable-select. Co-authored-by: Cursor <cursoragent@cursor.com>
204 lines
8.2 KiB
JavaScript
204 lines
8.2 KiB
JavaScript
/**
|
||
* Одиночний 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 = '×';
|
||
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);
|
||
});
|
||
};
|
||
})();
|