/** * @fileoverview Head Picker - Autocomplete для выбора руководителя отдела * Поддерживает поиск по имени и email, валидацию выбора * @module components/headPicker */ import { esc, norm } from '../utils/stringUtils.js'; /** * Инициализирует компонент выбора руководителя с автодополнением * @param {Object} options - Опции инициализации * @param {string} [options.selector='[data-head-picker]'] - Селектор контейнера * @returns {Object|null} API компонента или null если элемент не найден */ export function initHeadPicker(options = {}) { const { selector = '[data-head-picker]' } = options; // Защита от повторной инициализации if (window.__headPickerWithEmailBound) { return null; } window.__headPickerWithEmailBound = true; /** * Инициализирует один экземпляр picker * @param {HTMLElement} root - Корневой элемент с data-head-picker */ function initPicker(root) { const input = root.querySelector('input[type="text"]'); const hidden = root.querySelector('input[type="hidden"][name="head_id"]'); const menu = root.querySelector('.dropdown-menu'); if (!input || !hidden || !menu) { console.warn('HeadPicker: missing required elements', root); return; } // Парсим данные из data-атрибута const choices = (JSON.parse(root.dataset.choices || '[]') || []).map(c => ({ id: c.id, name: c.name || '', email: c.email || '' })); /** * Поиск по ID */ function findById(id) { return choices.find(c => String(c.id) === String(id)); } /** * Точный поиск по имени или email */ function findByExact(text) { const t = text.trim(); const byName = choices.find(c => c.name === t); if (byName) return byName; const byEmail = choices.find(c => c.email && norm(c.email) === norm(t)); return byEmail || null; } // Синхронизация начальных значений if (hidden.value && !input.value) { const item = findById(hidden.value); if (item) input.value = item.name || item.email || ''; } if (input.value && !hidden.value) { const m = findByExact(input.value); if (m) hidden.value = m.id; } /** * Отображает список выбора */ function listItems(arr) { if (!arr.length) { menu.innerHTML = ''; } else { menu.innerHTML = arr.map(c => ` `).join(''); } if (!menu.classList.contains('show')) { menu.classList.add('show'); } } /** * Фильтрует список по запросу */ function filter(q) { const s = norm(q); const res = s ? choices.filter(c => norm(c.name).includes(s) || norm(c.email).includes(s)).slice(0, 8) : choices.slice(0, 8); listItems(res); } /** * Скрывает меню */ function hideMenu() { menu.classList.remove('show'); } // События input input.addEventListener('focus', () => filter(input.value)); input.addEventListener('input', () => { hidden.value = ''; input.classList.remove('is-invalid'); filter(input.value); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const first = menu.querySelector('.dropdown-item:not(.disabled)'); if (menu.classList.contains('show') && first) { e.preventDefault(); first.click(); } } else if (e.key === 'Escape') { hideMenu(); } }); // Выбор элемента из меню menu.addEventListener('click', (e) => { const btn = e.target.closest('.dropdown-item'); if (!btn || btn.classList.contains('disabled')) return; input.value = btn.dataset.name || btn.dataset.email || ''; hidden.value = btn.dataset.id || ''; input.classList.remove('is-invalid'); hideMenu(); }); // Закрытие при клике вне компонента document.addEventListener('click', (e) => { if (!root.contains(e.target)) hideMenu(); }); // Валидация при отправке формы const form = root.closest('form'); form?.addEventListener('submit', (e) => { const val = input.value.trim(); // Пустое значение - OK if (val === '') { hidden.value = ''; return; } // Если hidden не заполнен, пробуем найти точное совпадение if (!hidden.value) { const m = findByExact(val); if (m) { hidden.value = m.id; return; } // Не найдено - показываем ошибку e.preventDefault(); input.classList.add('is-invalid'); filter(val); input.focus(); } }); } // Инициализируем все найденные пикеры const pickers = document.querySelectorAll(selector); pickers.forEach(initPicker); return { /** * Переинициализирует все пикеры (для динамически добавленных элементов) */ refresh: () => { window.__headPickerWithEmailBound = false; initHeadPicker(options); }, /** Количество проинициализированных пикеров */ count: pickers.length }; } // Публикуем в window для совместимости if (typeof window !== 'undefined') { window.initHeadPicker = initHeadPicker; }