/** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; import publicWidget from "@web/legacy/js/public/public_widget"; /** * M22 Helpdesk Select Enhancement Widget * * Transforms standard select dropdowns in helpdesk forms into modern * searchable selects using vanilla JavaScript + Tailwind CSS. * No external dependencies - pure modern JavaScript. */ publicWidget.registry.M22HelpdeskSelect = publicWidget.Widget.extend({ selector: '#helpdesk_ticket_form, form[id*="helpdesk"], form[data-model_name="helpdesk.ticket"]', /** * @override */ start: function () { const self = this; return this._super.apply(this, arguments).then(function () { // Wait a bit for form fields to be fully rendered setTimeout(function () { self._initializeSelects(); }, 300); }); }, /** * @override */ destroy: function () { // Cleanup click outside handlers const comboboxes = this.el.querySelectorAll('.m22-combobox-wrapper'); comboboxes.forEach(wrapper => { if (wrapper._clickOutsideHandler) { document.removeEventListener('click', wrapper._clickOutsideHandler); delete wrapper._clickOutsideHandler; } }); this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Initialize combobox on all select elements in the form * @private */ _initializeSelects: function () { // Find all select elements with class s_website_form_input (many2one fields) // Exclude selects that are already initialized const selects = this.el.querySelectorAll('select.s_website_form_input:not(.m22-combobox-processed)'); if (!selects.length) { return; } selects.forEach(select => { try { select.classList.add('m22-combobox-processed'); this._transformSelectToCombobox(select); } catch (error) { console.error(_t('Error initializing combobox:'), error); } }); }, /** * Transform a select element into a modern combobox * @param {HTMLElement} select * @private */ _transformSelectToCombobox: function (select) { // Get options from select const options = Array.from(select.options).map(opt => ({ value: opt.value, label: opt.textContent.trim(), disabled: opt.disabled })).filter(opt => opt.value || opt.label); // Get current value const currentValue = select.value; const currentOption = options.find(opt => opt.value === currentValue); const placeholder = select.querySelector('option[disabled]')?.textContent?.trim() || _t('Select an option...'); const searchPlaceholder = _t('Search...'); const noResultsText = _t('No results found'); // Create wrapper div const wrapper = document.createElement('div'); wrapper.className = 'relative m22-combobox-wrapper'; // Create state object const state = { open: false, search: '', selected: currentOption || null, options: options.filter(opt => !opt.disabled), placeholder: placeholder, searchPlaceholder: searchPlaceholder, noResultsText: noResultsText, get filteredOptions() { if (!this.search) return this.options; const query = this.search.toLowerCase(); return this.options.filter(opt => opt.label.toLowerCase().includes(query) ); }, get hasResults() { return this.filteredOptions.length > 0; } }; // Build HTML structure with Tailwind classes wrapper.innerHTML = ` `; // Get references to elements const button = wrapper.querySelector('.m22-combobox-button'); const dropdown = wrapper.querySelector('.m22-combobox-dropdown'); const searchInput = wrapper.querySelector('.m22-combobox-search'); const optionsContainer = wrapper.querySelector('.m22-combobox-options'); const noResults = wrapper.querySelector('.m22-combobox-no-results'); const placeholderSpan = wrapper.querySelector('.m22-combobox-placeholder'); const valueSpan = wrapper.querySelector('.m22-combobox-value'); const arrow = wrapper.querySelector('.m22-combobox-arrow'); const hiddenSelect = wrapper.querySelector('select'); // Methods const updateDisplay = () => { if (state.selected) { placeholderSpan.classList.add('hidden'); valueSpan.textContent = state.selected.label; valueSpan.classList.remove('hidden'); button.classList.remove('text-gray-500'); button.classList.add('text-gray-900'); } else { placeholderSpan.classList.remove('hidden'); valueSpan.classList.add('hidden'); button.classList.remove('text-gray-900'); button.classList.add('text-gray-500'); } }; const renderOptions = () => { const filtered = state.filteredOptions; if (filtered.length === 0 && state.search) { optionsContainer.classList.add('hidden'); noResults.classList.remove('hidden'); } else { optionsContainer.classList.remove('hidden'); noResults.classList.add('hidden'); // Update options highlighting optionsContainer.querySelectorAll('.m22-combobox-option').forEach(optionEl => { const optionValue = optionEl.dataset.value; const isSelected = state.selected && state.selected.value === optionValue; optionEl.classList.toggle('bg-blue-100', isSelected); // Update checkmark const checkmark = optionEl.querySelector('.absolute'); if (isSelected && !checkmark) { const check = document.createElement('span'); check.className = 'absolute inset-y-0 right-0 flex items-center pr-4 text-blue-600'; check.innerHTML = ` `; optionEl.appendChild(check); } else if (!isSelected && checkmark) { checkmark.remove(); } // Show/hide based on search const option = state.options.find(o => o.value === optionValue); if (option) { const matches = !state.search || option.label.toLowerCase().includes(state.search.toLowerCase()); optionEl.style.display = matches ? '' : 'none'; } }); } }; const selectOption = (option) => { state.selected = option; state.search = ''; state.open = false; // Update hidden select hiddenSelect.value = option.value; // Update display updateDisplay(); renderOptions(); closeDropdown(); // Trigger change event const event = new Event('change', { bubbles: true }); hiddenSelect.dispatchEvent(event); }; const openDropdown = () => { state.open = true; dropdown.classList.remove('hidden'); arrow.classList.add('rotate-180'); searchInput.focus(); renderOptions(); }; const closeDropdown = () => { state.open = false; dropdown.classList.add('hidden'); arrow.classList.remove('rotate-180'); state.search = ''; searchInput.value = ''; renderOptions(); }; // Event listeners button.addEventListener('click', (e) => { e.stopPropagation(); if (state.open) { closeDropdown(); } else { openDropdown(); } }); searchInput.addEventListener('input', (e) => { state.search = e.target.value; renderOptions(); }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeDropdown(); } else if (e.key === 'Enter' && state.filteredOptions.length > 0) { e.preventDefault(); selectOption(state.filteredOptions[0]); } }); optionsContainer.addEventListener('click', (e) => { const optionEl = e.target.closest('.m22-combobox-option'); if (optionEl) { const value = optionEl.dataset.value; const option = state.options.find(o => o.value === value); if (option) { selectOption(option); } } }); // Click outside handler const clickOutsideHandler = (e) => { if (state.open && !wrapper.contains(e.target)) { closeDropdown(); } }; document.addEventListener('click', clickOutsideHandler); wrapper._clickOutsideHandler = clickOutsideHandler; // Keyboard navigation optionsContainer.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeDropdown(); button.focus(); } }); // Replace select with wrapper select.parentNode.insertBefore(wrapper, select); select.style.display = 'none'; wrapper.appendChild(select); // Initial render updateDisplay(); renderOptions(); }, /** * Escape HTML to prevent XSS * @param {string} text * @returns {string} * @private */ _escapeHtml: function(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } });