/
Dmitry-F
/
cbt_tool
Обзор
Документация
Войти
/
Dmitry-F
/
cbt_tool
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
python-ml-service
app/javascript/controllers/search_component.js
182 строки
5 KB
Dmitriy-Filatov
feat: архитектурная стабилизация ассетов и глобальный поиск
10 мар 2026, 21:21
10 мар 2026, 21:21
4c1bebf
Код
Авторство
О чём код?
// SearchComponent — полноценный компонент поиска с A11y и клавиатурной навигацией class SearchComponent { constructor(inputSelector, resultsSelector) { this.input = document.querySelector(inputSelector); this.resultsContainer = document.querySelector(resultsSelector); this.allClients = []; this.timeout = null; this.selectedIndex = -1; this.init(); } init() { if (!this.input || !this.resultsContainer) return; const clientsData = this.input.dataset.clients; if (clientsData) { try { this.allClients = JSON.parse(clientsData); } catch (e) { console.error("Ошибка парсинга данных клиентов:", e); } } this.input.addEventListener("input", this.handleInput.bind(this)); this.input.addEventListener("keydown", this.handleKeyNavigation.bind(this)); this.input.addEventListener("blur", this.handleBlur.bind(this)); document.addEventListener("keydown", (e) => { if (e.key === "Escape") this.hideResults(); }); document.addEventListener("click", (e) => { if ( !this.input.contains(e.target) && !this.resultsContainer.contains(e.target) ) { this.hideResults(); } }); } handleInput(e) { clearTimeout(this.timeout); const value = e.target.value.trim(); if (!value) { this.hideResults(); return; } this.timeout = setTimeout(() => this.filterAndRender(value), 300); } filterAndRender(searchTerm) { const filtered = this.allClients .filter((client) => client.name && client.name.length > 1) .filter( (client) => client.name.toLowerCase().includes(searchTerm.toLowerCase()) || (client.email && client.email.toLowerCase().includes(searchTerm.toLowerCase())), ) .slice(0, 10); this.renderResults(filtered, searchTerm); } renderResults(clients, searchTerm) { if (clients.length === 0) { this.resultsContainer.innerHTML = '<div class="px-4 py-3 text-center text-slate-400">Клиенты не найдены</div>'; this.updateAria(0); this.resultsContainer.classList.remove("hidden"); return; } // ВАЖНО: Добавил класс client-card обратно, чтобы работали события this.resultsContainer.innerHTML = clients .map( (client, index) => ` <div role="option" class="client-card block bg-white hover:bg-slate-50 p-4 border-b border-slate-100 last:border-b-0 last:mb-1 last:pb-1 first:rounded-t-xl last:rounded-b-xl transition cursor-pointer" tabindex="0" aria-selected="false" data-client-id="${client.id}" data-index="${index}" > <div class="font-medium text-slate-800 text-sm"> ${this.highlightMatch(this.escapeHtml(client.name), searchTerm)} </div> <div class="text-sm text-slate-500 text-[11px]">${this.escapeHtml(client.email || "")}</div> </div> `, ) .join(""); this.updateAria(clients.length); this.attachEventListeners(); this.resultsContainer.classList.remove("hidden"); } // Метод для подсветки совпадений highlightMatch(text, searchTerm) { if (!searchTerm || !text) return text; const regex = new RegExp(`(${this.escapeRegex(searchTerm)})`, "gi"); return text.replace( regex, '<span class="font-bold text-blue-600">$1</span>', ); } escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } escapeHtml(text) { if (!text) return ""; const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } attachEventListeners() { this.resultsContainer.querySelectorAll(".client-card").forEach((card) => { card.addEventListener("click", this.handleCardClick.bind(this)); }); } handleCardClick(e) { const card = e.target.closest(".client-card"); const clientId = card.dataset.clientId; if (clientId) { window.location.href = `/clients/${clientId}`; } } updateAria(count) { this.resultsContainer.setAttribute("aria-expanded", count > 0); } hideResults() { this.resultsContainer.classList.add("hidden"); this.resultsContainer.innerHTML = ""; this.selectedIndex = -1; } handleKeyNavigation(e) { const items = this.resultsContainer.querySelectorAll(".client-card"); if (items.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); this.selectedIndex = (this.selectedIndex + 1) % items.length; this.updateSelection(items); } else if (e.key === "ArrowUp") { e.preventDefault(); this.selectedIndex = this.selectedIndex <= 0 ? items.length - 1 : this.selectedIndex - 1; this.updateSelection(items); } else if (e.key === "Enter" && this.selectedIndex >= 0) { items[this.selectedIndex].click(); } } updateSelection(items) { items.forEach((item, index) => { const isSelected = index === this.selectedIndex; item.setAttribute("aria-selected", isSelected); item.classList.toggle("bg-slate-100", isSelected); if (isSelected) item.focus(); }); } handleBlur() { setTimeout(() => { if (!this.resultsContainer.contains(document.activeElement)) { this.hideResults(); } }, 200); } } export default SearchComponent;