PageSourceSearch

https://www.caser.es/resources/js/gpjs/forms/validation_dropdown.js

js caser.es collected 2026-10-02 03:55:44 UTC 18,734 bytes, 496 lines download raw bytes

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.