1/** 2 * @resumen JS validation Select ( ecli-form__dropdown ) 3 * @descripcion Operaciones para las validaciones correspondientes del componente select de los formularios 4 * @fichero validation_select.js 5 * @version 2.1 (parches mÃnimos para integración de data-error con submit global) 6 * @autor Jose M. Coronado - [email protected] 7 * * 8 * Este fichero pertenece a CASER 9 */ 10 11$(window).on("load", function () { 12 const $dropdowns = $('.ecli-form__dropdown'); 13 const $form = $('form'); 14 15 // Recorremos todos los $('.ecli-form__dropdown') para aplicar los eventos principales 16 window.resetDropdown = resetDropdown; 17 window.buildDropdown = buildDropdown; 18 19 $dropdowns.each(function () { 20 const $dropdown = $(this); 21 const id = $dropdown.data("id"); 22 const $select = $("#" + id); 23 24 buildDropdown($dropdown); 25 observeSelectChanges($select, $dropdown); // activar observador 26 }); 27 28 // Global para todos los dropdowns 29 watchGlobalSelectReinsertions(); 30 31 /* --------------------------------------------------------------------------------------- 32 * # SUBMIT: Validación visual de dropdowns (error visual + validación data-error). 33 La validación lógica (e.preventDefault) está en submit_validation_global.js 34 * --------------------------------------------------------------------------------------- */ 35 $form.on('submit', function () { 36 $dropdowns.each(function () { 37 const $dropdown = $(this); 38 const id = $dropdown.data("id"); 39 const $select = $("#" + id); 40 const $errorLabel = $("#" + id + "_error-label"); 41 42 // No bloqueamos aquà 43 // Solo marcamos data-error=true si es required y está vacÃo. 44 if ($select.attr("ecli-required") === "true" && (!$select.val() || $select.val().trim() === "")) { 45 showError($dropdown, $errorLabel); // pinta error visual 46 $select.attr("data-error", "true"); // NUEVO: señal al global 47 } else { 48 hideError($dropdown, $errorLabel); // limpia clases 49 } 50 }); 51 }); 52}); 53 54/* ---------------------------------------------------------------------------- 55 * # FUNCIÃN: Marca el label como activo si el campo tiene valor o es readonly 56 * ---------------------------------------------------------------------------- */ 57function toggleLabelActive($dropdown, $label, $select) { 58 // Si solo viene un argumento -> es el input 59 if ($dropdown && !$label && !$select) { 60 const $input = $dropdown instanceof jQuery ? $dropdown : $($dropdown); 61 const $labelFound = $input.siblings('.ecli-form__label'); 62 const value = ($input.val() || "").trim(); 63 64 if ($input.prop('readonly') || value !== "") { 65 $labelFound.addClass('active'); 66 } else { 67 $labelFound.removeClass('active'); 68 } 69 return; 70 } 71 72 // Si vienen los 3 -> versión clásica original 73 const $sel = $select instanceof jQuery ? $select : $($select); 74 const value = $.trim($sel.val()); 75 76 if ($dropdown.prop('readonly')) { 77 $label.addClass('active'); 78 } else if (!value) { 79 $label.removeClass('active'); 80 } else { 81 $label.addClass('active'); 82 } 83} 84 85/* ---------------------------------------------------------------------------- 86 * # FUNCIÃN: Muestra/Oculta mensaje de error, clases error y nuevo data-error 87 * ---------------------------------------------------------------------------- */ 88function showError($dropdown, $errorLabel) { 89 $errorLabel.show(); 90 $dropdown.addClass('error'); 91 $dropdown.closest('.ecli-form__group').addClass('error'); 92 93 // NUEVO: marcar data-error en el select real (no en el div) 94 const id = $dropdown.data("id"); 95 const $select = $("#" + id); 96 $select.attr("data-error", "true"); 97} 98 99function hideError($dropdown, $errorLabel) { 100 $errorLabel.hide(); 101 $dropdown.removeClass('error'); 102 $dropdown.closest('.ecli-form__group').removeClass('error'); 103 104 // NUEVO: quitar data-error 105 const id = $dropdown.data("id"); 106 const $select = $("#" + id); 107 $select.removeAttr("data-error"); 108} 109 110/* ---------------------------------------------------------------------------- 111 * # FUNCIÃN: Comprobamos la posición del listado. Si no cabe en pantalla lo posiciona arriba con [data-list="top"] 112 * ---------------------------------------------------------------------------- */ 113function adjustDropdownDirection($dropdown) { 114 const $list_dropdown = $dropdown.find('.ecli-form__list'); 115 if (!$list_dropdown.length) return; 116 117 $list_dropdown.removeAttr("data-list"); 118 119 const list = $list_dropdown[0]; 120 const rect = list.getBoundingClientRect(); 121 const listHeight = list.offsetHeight; 122 const spaceBelow = window.innerHeight - rect.bottom; 123 const spaceAbove = rect.top; 124 125 const willOverflowBelow = spaceBelow < 0; 126 const fitsAbove = spaceAbove > listHeight; 127 128 if (willOverflowBelow && fitsAbove) { 129 $list_dropdown.attr("data-list", "top"); 130 } 131} 132 133/* ---------------------------------------------------------------------------- 134 * # FUNCIÃN: Al iniciar, seleccionamos valor si el select ya tiene valor al cargar (volver atrás) 135 * ---------------------------------------------------------------------------- */ 136function syncDropdownWithSelect($dropdown, $select, $value_dropdown, $list_dropdown) { 137 if (!$list_dropdown.length) return; 138 const val = $select.val(); 139 140 // Detectar si el valor es "vacÃo" aunque sea "0" 141 const isPlaceholderValue = ( 142 val === "" || 143 val === "0" && !$select.find("option[value='0']").is(":selected") 144 ); 145 if (isPlaceholderValue) return; 146 147 const $match = $list_dropdown.find('li').filter(function () { 148 return $(this).data("value") == val; 149 }); 150 if ($match.length) { 151 $value_dropdown.text($match.text().trim()); 152 $list_dropdown.find("li").removeAttr("aria-selected"); 153 $match.attr("aria-selected", "true"); 154 } 155} 156 157/* ---------------------------------------------------------------------------- 158 * # FUNCIÃN: Reseteo de Dropdown 159 * ---------------------------------------------------------------------------- */ 160// Función para vaciar el select desde la ftl 161function resetDropdown(selectId) { 162 const $nativeSelect = $(`#${selectId}`); 163 const $dropdown = $(`.ecli-form__dropdown[data-id="${selectId}"]`); 164 const $trigger = $dropdown.find('.ecli-form__dropdown-value'); 165 const $listItems = $dropdown.find('li[aria-selected="true"]'); 166 const $errorLabel = $("#" + selectId + "_error-label"); 167 console.log("Ejecutando resetDropdown en " + $nativeSelect); 168 169 // Reset lógico 170 $nativeSelect.val('').trigger('change'); 171 $nativeSelect.find('option[selected]').removeAttr('selected'); 172 173 // Reset visual 174 $trigger.text($trigger.attr('data-placeholder') || ''); 175 $listItems.removeAttr('aria-selected'); 176 177 hideError($dropdown, $errorLabel); 178 toggleClearIcon($dropdown, $nativeSelect); 179 toggleLabelActive($dropdown, $("#" + selectId + "_label"), $nativeSelect); 180} 181 182// Cerrar todos los dropdowns 183function closeAllDropdowns() { 184 $('.ecli-form__dropdown').removeClass('open').attr("aria-expanded", "false"); 185} 186 187// Mostrar / Ocultar botón de cerrar 188function toggleClearIcon($dropdown, $select) { 189 const $iconClose = $dropdown.find('.ecli-form__dropdown-clear'); 190 const value = $.trim($select.val()); 191 if (value !== "") { 192 $iconClose.show(); 193 } else { 194 $iconClose.hide(); 195 } 196} 197 198// EVENTO: Cierra el desplegable si se hace clic fuera 199$(document).on("click", function (e) { 200 if (!$(e.target).closest('.ecli-form__dropdown').length) { 201 closeAllDropdowns(); 202 } 203}); 204 205/* ---------------------------------------------------------------------------- 206 * # EVENTO PRINCIPAL: Aplicacmos toda la funcionalidad del select/dropdown 207 * ---------------------------------------------------------------------------- */ 208function buildDropdown($dropdown) { 209 const id = $dropdown.data("id"); 210 const $select = $("#" + id); // ahora es un <select hidden> 211 const $label = $("#" + id + "_label"); 212 const $errorLabel = $("#" + id + "_error-label"); 213 const $list_dropdown = $dropdown.find(".ecli-form__list"); 214 const $value_dropdown = $dropdown.find(".ecli-form__dropdown-value"); 215 const isRequired = $select.attr("ecli-required") === "true"; 216 let typedChars = ""; 217 let typeTimeout; 218 219 // Inicializar estado del label 220 toggleLabelActive($dropdown, $label, $select); 221 // Al iniciar, seleccionamos valor si el select ya tiene valor al cargar (volver atrás) 222 syncDropdownWithSelect($dropdown, $select, $value_dropdown, $list_dropdown); 223 224 // ## EVENTO Click: Abre/cierra el desplegable, y cierra otros selects abiertos 225 $dropdown.on("click", function (e) { 226 if ($(e.target).closest('.ecli-form__dropdown-clear').length) return; 227 const isOpen = $dropdown.hasClass("open"); 228 closeAllDropdowns(); 229 230 if (!isOpen) { 231 $dropdown.addClass("open").attr("aria-expanded", "true"); 232 $label.addClass("active"); 233 // Esperamos al repaint para que la lista esté visible antes de medir 234 setTimeout(() => { 235 adjustDropdownDirection($dropdown); 236 }, 0); 237 } else { 238 // Si estaba abierto y se cierra, ajustar estado del dropdown y el label según valor del select 239 $dropdown.removeClass("open").attr("aria-expanded", "false"); 240 $dropdown.closest(".ecli-form__group").removeClass("open"); 241 toggleLabelActive($dropdown, $label, $select); 242 } 243 });
244 245 // ## EVENTO Blur: Cierra todos los dropdowns 246 $dropdown.on('blur', function () { 247 toggleLabelActive($dropdown, $label, $select); 248 $dropdown.closest(".ecli-form__group").removeClass("open"); 249 }); 250 251 // ## EVENTO Click en li: Selección de una opción del listado. Actualiza el valor visible, el input hidden y oculta errores 252 $dropdown.on("click", "li", function (e) { 253 const $selectedItem = $(this); 254 const val = $selectedItem.data("value"); 255 const text = $selectedItem.text().trim(); 256 $dropdown.removeClass("keyboard-navigation"); 257 258 // Si selecciono un li del dropdown, cambia el value del select y el selected del <option> correspondiente 259 $select.find('option').each(function () { 260 $(this).prop('selected', false).removeAttr('selected'); 261 }); 262 $select.find(`option[value="${val}"]`).each(function () { 263 $(this).prop('selected', true).attr('selected', 'selected'); 264 }); 265 $select.val(val).trigger("change"); 266 267 $value_dropdown.text(text); 268 $list_dropdown.find("li").removeAttr("aria-selected"); 269 $selectedItem.attr("aria-selected", "true"); 270 $dropdown.removeClass("open").attr("aria-expanded", "false"); 271 272 // Cierra el dropdown de forma segura sin interferir con el document.click 273 setTimeout(() => { 274 closeAllDropdowns(); 275 }, 0); 276 277 toggleLabelActive($dropdown, $label, $select); 278 toggleClearIcon($dropdown, $select); 279 hideError($dropdown, $errorLabel); 280 }); 281 282 // ## EVENTO Click en el icono de limpiar 283 $(document).on('click', '.ecli-form__dropdown-clear', function (e) { 284 e.preventDefault(); 285 e.stopPropagation(); // clave para evitar que se propague el click al dropdown 286 287 const $dropdownRoot = $(this).closest('.ecli-form__dropdown'); 288 const dropdownId = $dropdownRoot.data("id"); 289 resetDropdown(dropdownId); 290 }); 291 292 // ## EVENTO Keydown: Añadimos accesibiidad con teclado 293 $dropdown.on("keydown", function (e) { 294 if ($dropdown.attr("aria-disabled") === "true") return; 295 296 const $options = $list_dropdown.find("li"); 297 let $focused = $options.filter('[aria-selected="true"]'); 298 if (!$focused.length) $focused = $options.first(); 299 $dropdown.addClass("keyboard-navigation"); 300 301 // ## EVENTO Keydown Teclas de caracteres: búsqueda por letra(s) 302 const isPrintableChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; 303 304 if (isPrintableChar) { 305 typedChars += e.key.toLowerCase(); 306 307 clearTimeout(typeTimeout); 308 typeTimeout = setTimeout(() => { 309 typedChars = ""; 310 }, 3000); // reseteo del buffer para capturar letras de busqueda 311 312 const $optionsArray = $options.toArray(); 313 let startIndex = $options.index($options.filter('[aria-selected="true"]')); 314 315 // Buscamos desde la opción seleccionada hasta el final 316 let matchIndex = -1; 317 for (let i = startIndex + 1; i < $optionsArray.length; i++) { 318 const text = $options.eq(i).text().trim().toLowerCase(); 319 if (text.startsWith(typedChars)) { 320 matchIndex = i; 321 break; 322 } 323 } 324 325 // Si no hay coincidencias después, buscar desde el principio hasta la actual 326 if (matchIndex === -1) { 327 for (let i = 0; i <= startIndex; i++) { 328 const text = $options.eq(i).text().trim().toLowerCase(); 329 if (text.startsWith(typedChars)) { 330 matchIndex = i; 331 break; 332 } 333 } 334 } 335 336 if (matchIndex !== -1) { 337 const $match = $options.eq(matchIndex); 338 $options.removeClass("active-option").removeAttr("aria-selected"); 339 $match.attr("aria-selected", "true"); 340 341 // Asegura visibilidad 342 const listEl = $list_dropdown[0]; 343 const matchEl = $match[0]; 344 if (listEl && matchEl) { 345 listEl.scrollTop = matchEl.offsetTop - listEl.offsetTop; 346 } 347 348 // Abrir si estaba cerrado 349 if (!$dropdown.hasClass("open")) { 350 $dropdown.addClass("open").attr("aria-expanded", "true"); 351 adjustDropdownDirection($dropdown); 352 } 353 } 354 355 return; // Evita que se ejecute el switch para estas teclas 356 } 357 358 switch (e.key) { 359 case "Enter": 360 case " ": 361 e.preventDefault(); 362 if (!$dropdown.hasClass("open")) { 363 $dropdown.addClass("open").attr("aria-expanded", "true"); 364 } else { 365 $focused.click(); // Simula click en la opción activa 366 } 367 break; 368 case "ArrowDown": 369 e.preventDefault(); 370 let next = $focused.next(); 371 if (next.length) { 372 $options.removeClass("active-option").removeAttr("aria-selected"); 373 next.addClass("active-option").attr("aria-selected", "true"); 374 // Hacer scroll abajo al nuevo elemento activo 375 const listEl = $list_dropdown[0]; 376 const nextEl = next[0]; 377 if (listEl && nextEl) { 378 listEl.scrollTop = nextEl.offsetTop - listEl.offsetTop; 379 } 380 } 381 break; 382 case "ArrowUp": 383 e.preventDefault(); 384 let prev = $focused.prev(); 385 if (prev.length) { 386 $options.removeClass("active-option").removeAttr("aria-selected"); 387 prev.addClass("active-option").attr("aria-selected", "true"); 388 // Hacer scroll arriba al nuevo elemento activo 389 const listEl = $list_dropdown[0]; 390 const prevEl = prev[0]; 391 if (listEl && prevEl) { 392 listEl.scrollTop = prevEl.offsetTop - listEl.offsetTop; 393 } 394 } 395 break; 396 case "Escape": 397 closeAllDropdowns(); 398 break; 399 } 400 }); 401 402 // ## EVENTO Change (select): Observer para detectar cambios externos en el select hidden 403 $select.on("change", function () { 404 syncDropdownWithSelect($dropdown, $select, $value_dropdown, $list_dropdown); 405 toggleLabelActive($dropdown, $label, $select); 406 toggleClearIcon($dropdown, $select); 407 }); 408} 409 410/** 411 * @resumen Importante. Función que escucha los cambios de select mediante AJAX 412 * @descripcion Observa si se añaden o eliminan <option>s dentro de un <select>. 413 * Se usa para casos donde un <select> ya presente en el DOM cambia dinámicamente (AJAX, lógica de negocio, etc). 414 * 415 * @uso Cuando un select ya existe en el DOM, pero su contenido cambia (por ejemplo, #municipio cambia tras
415seleccionar una provincia). 416 * No se dispara cuando el select entero es eliminado y vuelto a insertar. 417 */ 418function observeSelectChanges($select, $dropdown) { 419 const observer = new MutationObserver(function (mutationsList) { 420 let optionsChanged = false; 421 422 for (let mutation of mutationsList) { 423 if (mutation.type === 'childList') { 424 optionsChanged = true; 425 break; 426 } 427 } 428 429 if (optionsChanged) { 430 console.log("Cambios detectados en opciones de", $select.attr("id")); 431 432 // Reiniciar visual y lógica del dropdown 433 const selectId = $select.attr("id"); 434 resetDropdown(selectId); // limpia estado 435 buildDropdown($dropdown); // reconstruye eventos y sincroniza 436 } 437 }); 438 439 // Observamos cambios en hijos (options) 440 observer.observe($select[0], { childList: true, subtree: false }); 441} 442 443/** 444 * @resumen Importante. Función que detecta los select nuevos mediante AJAX 445 * @descripcion Observa el document.body para detectar cuándo se añade un nuevo <select> (reinsertado, por ejemplo por JS, HTMX, o jQuery .html()). 446 * Se asegura de reinicializar los dropdowns cuando aparecen de nuevo en el DOM. 447 * 448 * @uso Cuando un bloque de HTML con un select es reemplazado completamente (caso tÃpico en formularios dinámicos, AJAX o FTLs). 449 * No detecta cambios internos de <option>, solo la reinserción del nodo select. 450 */ 451function watchGlobalSelectReinsertions() { 452 const observer = new MutationObserver((mutationsList) => { 453 for (let mutation of mutationsList) { 454 if (mutation.type === "childList") { 455 mutation.addedNodes.forEach((node) => { 456 if (node.nodeType !== 1) return; // Solo nodos tipo elemento 457 458 const selects = node ? node.querySelectorAll("select") : []; 459 selects.forEach(select => { 460 const $select = $(select); 461 const selectId = $select.attr("id"); 462 463 if (!selectId) return; 464 465 // Buscar dropdown hermano usando el data-id 466 const $dropdown = $(`[data-id="${selectId}"]`); 467 if ($dropdown.length > 0) { 468 console.log(`Select ${selectId} reinsertado, reinicializando dropdown`); 469 470 // Limpiar y volver a construir 471 resetDropdown(selectId); 472 buildDropdown($dropdown); 473 } 474 }); 475 }); 476 } 477 } 478 }); 479 480 observer.observe(document.body, { 481 childList: true, 482 subtree: true 483 }); 484} 485 486window.rebuildDropdowns = function () { 487 $('.ecli-form__dropdown').each(function () { 488 const $dropdown = $(this); 489 const id = $dropdown.data('id'); 490 491 if (id) { 492 resetDropdown(id); 493 buildDropdown($dropdown); 494 } 495 }); 496};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.