| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- /** @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 = `
- <!-- Hidden original select for form submission -->
- <select name="${select.name}" id="${select.id}" class="hidden" ${select.required ? 'required' : ''}>
- ${options.map(opt =>
- `<option value="${opt.value}" ${opt.value === currentValue ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''}>${opt.label}</option>`
- ).join('')}
- </select>
-
- <!-- Combobox button -->
- <button
- type="button"
- class="m22-combobox-button w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-left shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm ${state.selected ? 'text-gray-900' : 'text-gray-500'}"
- >
- <span class="m22-combobox-placeholder block truncate ${state.selected ? 'hidden' : ''}">${placeholder}</span>
- <span class="m22-combobox-value block truncate ${!state.selected ? 'hidden' : ''}">${state.selected ? state.selected.label : ''}</span>
- <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
- <svg class="m22-combobox-arrow h-5 w-5 text-gray-400 transition-transform" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
- <path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
- </svg>
- </span>
- </button>
-
- <!-- Dropdown panel -->
- <div
- class="m22-combobox-dropdown absolute z-[9999] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm hidden"
- >
- <!-- Search input -->
- <div class="sticky top-0 z-10 bg-white px-3 py-2 border-b border-gray-200">
- <input
- type="text"
- class="m22-combobox-search w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
- placeholder="${searchPlaceholder}"
- />
- </div>
-
- <!-- Options list -->
- <div class="m22-combobox-options">
- ${state.options.map(opt => `
- <div
- class="m22-combobox-option relative cursor-pointer select-none px-4 py-2 text-gray-900 hover:bg-blue-50 transition-colors ${state.selected && state.selected.value === opt.value ? 'bg-blue-100' : ''}"
- data-value="${opt.value}"
- >
- <span class="block truncate">${this._escapeHtml(opt.label)}</span>
- ${state.selected && state.selected.value === opt.value ? `
- <span class="absolute inset-y-0 right-0 flex items-center pr-4 text-blue-600">
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
- <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
- </svg>
- </span>
- ` : ''}
- </div>
- `).join('')}
- </div>
-
- <!-- No results -->
- <div class="m22-combobox-no-results px-4 py-2 text-sm text-gray-500 hidden">
- ${noResultsText}
- </div>
- </div>
- `;
- // 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 = `
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
- <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
- </svg>
- `;
- 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;
- }
- });
|