1// interface/base.js 2// UI base extraÃda sin alterar comportamiento. 3 4// interface.js 5 6import { buscarCoincidenciasDesdeSiteMap } from "../search/index.js?v=INSTITUTIONAL_SEARCH_CONSOLE_CLEAN_1_0_0_20260922"; 7import { getSiteConfig, isAllowedHost } from "../site-config-runtime.js?v=INSTITUTIONAL_SEARCH_PORTABILITY_3_3_20260918"; 8 9/* ============ Helpers ============ */ 10const debounce = (fn, ms = 250) => { 11 let t = null; 12 return (...args) => { 13 clearTimeout(t); 14 t = setTimeout(() => fn(...args), ms); 15 }; 16}; 17 18const lockScroll = () => { 19 const prev = document.documentElement.style.overflow; 20 document.documentElement.dataset.prevOverflow = prev || ""; 21 document.documentElement.style.overflow = "hidden"; 22}; 23const unlockScroll = () => { 24 document.documentElement.style.overflow = 25 document.documentElement.dataset.prevOverflow || ""; 26 delete document.documentElement.dataset.prevOverflow; 27}; 28 29/* ============ Entrada UI (conserva tu tamaño) ============ */ 30export function insertarBuscadorInput() { 31 const contenedor = document.getElementById("contenedor-buscador"); 32 if (contenedor) { 33 // Mismo HTML que pasaste (no se toca tamaño) 34 contenedor.innerHTML = ` 35 <section 36 class="flex items-end justify-center lg:justify-end" 37 id="search-section"> 38 <section class="flex flex-col"> 39 <div class="flex justify-end items-center gap-2 mb-2 text-sm"> 40 <a href="/_layouts/15/Authenticate.aspx" class="!text-black hover:underline whitespace-nowrap"> 41 Administrar sitio 42 </a> 43 </div> 44 45 <button 46 id="buscador" 47 type="button" 48 class="border-solid border-2 cursor-pointer border-color1 focus:outline-none rounded-l-lg px-3 bg-color2 h-12 flex items-center text-gray-500 w-full text-left text-lg transition" 49 style="min-width:220px;"> 50 <span class="flex-1 truncate">Buscar en la página</span> 51 52 </button> 53 </section> 54 <button 55 id="buscadorIcono" 56 type="button" 57 class="bg-color1 border-0 h-12 w-12 flex items-center justify-center !m-0 rounded-none rounded-r-lg"> 58 <img 59 src="/SiteAssets/v1/assets/img/header-footer/search-icon.svg" 60 alt="search icon" /> 61 </button> 62 </section> 63 `; 64 65 // Abrir modal con ambos botones (texto e Ãcono) 66 contenedor.querySelector("#buscador")?.addEventListener("click", () => { 67 crearBuscadorModal(); 68 }); 69 contenedor 70 .querySelector("#buscadorIcono") 71 ?.addEventListener("click", () => { 72 crearBuscadorModal(); 73 }); 74 } 75} 76 77/* ============ Modal + Animaciones (vÃa clases CSS) ============ */ 78export const crearBuscadorModal = () => { 79 if (document.getElementById("modal-buscador")) return; 80 81 const previousFocus = 82 document.activeElement instanceof HTMLElement 83 ? document.activeElement 84 : null; 85 86 let closing = false; 87 88 const siteConfig = 89 getSiteConfig(); 90 91 const branding = 92 siteConfig.branding; 93 94 const logoMarkup = 95 branding.logo 96 ? `<img src="${escapeHtml(branding.logo)}" alt="${escapeHtml(branding.logoAlt)}" class="h-[70px] w-full object-contain mb-2">` 97 : ""; 98 99 const overlay = document.createElement("div"); 100 overlay.id = "modal-buscador"; 101 overlay.setAttribute("role", "dialog"); 102 overlay.setAttribute("aria-modal", "true"); 103 overlay.setAttribute("aria-labelledby", "buscador-modal-title"); 104 overlay.setAttribute("aria-describedby", "buscador-modal-description"); 105 overlay.className = "buscador-overlay"; // estilos en CSS externo 106 107 const modal = document.createElement("div"); 108 modal.className = "buscador-panel isearch-ui36101-initial-hydrating"; // estilos en CSS externo 109 110 modal.innerHTML = ` 111 <!-- Botón cerrar --> 112 <button id="cerrarModalBtn" type="button" class="buscador-close" aria-label="Cerrar"> 113 <i class="fa-solid fa-xmark text-xl"></i> 114 </button> 115 116 <!-- Encabezado --> 117 <div class="flex flex-col items-center text-center mb-6"> 118 ${logoMarkup} 119 <h2 id="buscador-modal-title" class="text-lg font-semibold text-gray-700">${escapeHtml(branding.title)}</h2> 120 <p id="buscador-modal-description" class="text-sm text-gray-600 mt-1 max-w-md"> 121 Utiliza nuestro buscador con dictado por voz para encontrar información de forma rápida y precisa. 122 </p> 123 </div> 124 125 <!-- Input con papelera y micrófono --> 126 <div class="flex gap-2 items-center mb-4"> 127 <div class="relative flex-1"> 128 <input id="inputBusqueda" type="text" placeholder="Buscar contenido..." maxlength="120" autocomplete="off" aria-describedby="isearch-min-hint" 129 class="w-full pr-20 pl-4 py-3 text-sm border border-gray-300 rounded-lg 130 focus:outline-none focus:ring-2 focus:ring-blue-500" /> 131 <div class="absolute right-2 top-1/2 -translate-y-1/2 flex gap-2"> 132 <button id="clearBtn" 133 type="button" 134 class="buscador-chip-btn hidden" 135 title="Limpiar">
136 <i class="fa-solid fa-trash-can"></i> 137 </button> 138 <button id="micBtn" type="button" class="buscador-chip-btn" title="Hablar"> 139 <i class="fa-solid fa-microphone"></i> 140 </button> 141 </div> 142 </div> 143 </div> 144 145 <div id="isearch-min-hint" role="status" aria-live="polite"></div> 146 147 <!-- Loader --> 148 <div id="loader" class="buscador-loader mx-auto my-6" style="display: none;"></div> 149 150 <!-- Resultados --> 151 <div id="resultados" class="mt-4 max-h-80 overflow-y-auto divide-y divide-gray-100 152 text-sm text-gray-800 flex-1"></div> 153 <!-- Loader inicial UI 3.6.10.2: existe antes del primer paint --> 154 <div 155 id="isearch-ui-36101-loader-layer" 156 class="isearch-ui36101-loader-layer" 157 role="status" 158 aria-live="polite"> 159 <div class="isearch-ui36101-loader-card"> 160 <div class="isearch-ui36106-color-loader" aria-hidden="true"> 161 <span style="--isearch-loader-color:#048942;--isearch-loader-delay:0ms"></span> 162 <span style="--isearch-loader-color:#90B18A;--isearch-loader-delay:90ms"></span> 163 <span style="--isearch-loader-color:#E6E5E7;--isearch-loader-delay:180ms"></span> 164 <span style="--isearch-loader-color:#99B8DD;--isearch-loader-delay:270ms"></span> 165 <span style="--isearch-loader-color:#1E8BCC;--isearch-loader-delay:360ms"></span> 166 <span style="--isearch-loader-color:#936571;--isearch-loader-delay:450ms"></span> 167 <span style="--isearch-loader-color:#BD221E;--isearch-loader-delay:540ms"></span> 168 </div> 169 <div class="isearch-ui36101-loader-title">Preparando buscador</div> 170 <div class="isearch-ui36101-loader-text">Cargando información institucional</div> 171 </div> 172 </div> 173 174 175 <!-- Footer --> 176 <div class="mt-4 text-xs flex justify-between"> 177 <span id="tiempo-busqueda" class="text-blue-600 font-medium">Tiempo: -</span>
178 <span id="cantidad-resultados" class="text-gray-700 font-medium">Resultados: -</span> 179 </div> 180 `; 181 182 overlay.appendChild(modal); 183 document.body.appendChild(overlay); 184 185 // Abrir con animación (vÃa clase) 186 requestAnimationFrame(() => { 187 overlay.classList.add("is-open"); 188 modal.classList.add("is-open"); 189 }); 190 lockScroll(); 191 192 // === Refs === 193 const input = modal.querySelector("#inputBusqueda"); 194 const clearBtn = modal.querySelector("#clearBtn"); 195 const micBtn = modal.querySelector("#micBtn"); 196 const loader = modal.querySelector("#loader"); 197 const resultados = modal.querySelector("#resultados"); 198 const tiempoSpan = modal.querySelector("#tiempo-busqueda"); 199 const cantidadSpan = modal.querySelector("#cantidad-resultados"); 200 const cerrarModalBtn = modal.querySelector("#cerrarModalBtn"); 201 const minHint = modal.querySelector("#isearch-min-hint"); 202 203 // Focus inicial + trap 204 input.focus(); 205 const focusables = () => 206 modal.querySelectorAll( 207 'button, [href], input, [tabindex]:not([tabindex="-1"])' 208 ); 209 const trap = (e) => { 210 if (e.key !== "Tab") return; 211 const nodes = Array.from(focusables()); 212 if (!nodes.length) return; 213 const first = nodes[0]; 214 const last = nodes[nodes.length - 1]; 215 if (e.shiftKey && document.activeElement === first) { 216 last.focus(); 217 e.preventDefault(); 218 } else if (!e.shiftKey && document.activeElement === last) { 219 first.focus(); 220 e.preventDefault(); 221 } 222 }; 223 modal.addEventListener("keydown", trap); 224 225 // Cerrar (clic fuera + botón + ESC) con cleanup explÃcito. 226 const restorePreviousFocus = () => { 227 if ( 228 !previousFocus || 229 !previousFocus.isConnected || 230 typeof previousFocus.focus !== "function" 231 ) { 232 return; 233 } 234 235 try { 236 previousFocus.focus({ preventScroll: true }); 237 } catch { 238 previousFocus.focus(); 239 } 240 }; 241 242 const animateClose = () => { 243 if (closing) return; 244 closing = true; 245 246 document.removeEventListener( 247 "keydown", 248 onDocumentKeydown 249 ); 250 251 modal.removeEventListener( 252 "keydown", 253 trap 254 ); 255 256 overlay.classList.remove("is-open"); 257 modal.classList.remove("is-open"); 258 259 // esperar a fin de transición CSS (200â250ms) 260 setTimeout(() => { 261 overlay.remove(); 262 unlockScroll(); 263 restorePreviousFocus(); 264 }, 250); 265 }; 266 267 const onDocumentKeydown = (e) => { 268 if ( 269 e.key === "Escape" && 270 document.getElementById("modal-buscador") 271 ) { 272 e.preventDefault(); 273 animateClose(); 274 } 275 }; 276 277 cerrarModalBtn.onclick = animateClose; 278 279 overlay.addEventListener("click", (e) => { 280 if (e.target === overlay) { 281 animateClose(); 282 } 283 }); 284 285 document.addEventListener( 286 "keydown", 287 onDocumentKeydown 288 ); 289 290 /* ====== COMPORTAMIENTO PEDIDO ====== 291 - Si el input está vacÃo o tiene < 3 caracteres â no se muestra nada 292 - Clear aparece apenas hay texto 293 */ 294 const clearUI = () => { 295 resultados.innerHTML = ""; 296 loader.style.display = "none"; 297 tiempoSpan.textContent = "Tiempo: -"; 298 cantidadSpan.textContent = "Resultados: -"; 299 }; 300 301 const toggleClear = () => { 302 const has = input.value.trim().length > 0; 303 clearBtn.classList.toggle("hidden", !has); 304 }; 305 306 const updateMinHint = () => { 307 if (!minHint) return; 308 309 const length = 310 input.value.trim().length; 311 312 const isShort = 313 length > 0 && 314 length < 3; 315 316 modal.classList.toggle( 317 "isearch-ui36105-short-query", 318 isShort 319 ); 320 321 if (isShort) { 322 const missing = 323 3 - length; 324 325 minHint.textContent = 326 missing === 1 327 ? "MÃnimo 3 caracteres · falta 1" 328 : `MÃnimo 3 caracteres · faltan ${missing}`; 329 330 minHint.classList.add( 331 "is-visible" 332 ); 333 } else { 334 minHint.textContent = ""; 335 minHint.classList.remove( 336 "is-visible" 337 ); 338 } 339 }; 340 341 input.addEventListener("input", () => { 342 toggleClear(); 343 updateMinHint(); 344 345 // Con menos de 3 caracteres: limpiar y no buscar 346 if (input.value.trim().length < 3) { 347 clearUI(); 348 return; 349 } 350 // Si hay 3+ caracteres, el debounce (más abajo) hará la búsqueda 351 }); 352 353 clearBtn.addEventListener("click", () => { 354 input.value = ""; 355 toggleClear(); 356 clearUI(); 357 updateMinHint(); 358 input.focus(); 359 }); 360 361 // Buscar en vivo (debounce) + Enter 362 const doSearch = () => 363 ejecutarBusqueda(input, resultados, loader, tiempoSpan, cantidadSpan); 364 365 input.addEventListener( 366 "input", 367 debounce(() => { 368 if (input.value.trim().length >= 3) doSearch(); 369 }, 250) 370 ); 371 372 input.addEventListener("keydown", (e) => { 373 if (e.key === "Enter") { 374 if (input.value.trim().length < 3) { 375 clearUI(); // garantizar que no quede nada mostrado 376 return; 377 } 378 doSearch(); 379 } 380 }); 381 382 // Dictado por voz 383 if ("webkitSpeechRecognition" in window || "SpeechRecognition" in window) { 384 const SpeechRecognition = 385 window.SpeechRecognition || window.webkitSpeechRecognition; 386 const recognition = new SpeechRecognition(); 387 recognition.lang = "es-CO"; 388 recognition.interimResults = false; 389 recognition.maxAlternatives = 1; 390 391 micBtn.addEventListener("click", () => { 392 recognition.start(); 393 micBtn.classList.add("buscador-pulse"); 394 });
395 396 recognition.onresult = (event) => { 397 let textoVoz = event.results[0][0].transcript.trim(); 398 if (textoVoz.endsWith(".")) textoVoz = textoVoz.slice(0, -1); 399 input.value = textoVoz; 400 toggleClear(); // â muestra el clear inmediatamente 401 updateMinHint(); 402 403 if (input.value.trim().length < 3) { 404 clearUI(); 405 } else { 406 doSearch(); 407 } 408 }; 409 410 recognition.onerror = () => micBtn.classList.remove("buscador-pulse"); 411 recognition.onend = () => micBtn.classList.remove("buscador-pulse"); 412 } else { 413 micBtn.disabled = true; 414 micBtn.title = "Tu navegador no soporta dictado por voz"; 415 } 416}; 417 418/* ============ Búsqueda + render ============ */ 419function ejecutarBusqueda(input, resultados, loader, tiempoSpan, cantidadSpan) { 420 const texto = input.value.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim().slice(0, 120); 421 if (texto.length < 3) return; 422 423 resultados.innerHTML = ""; 424 loader.style.display = "block"; 425 tiempoSpan.textContent = "Tiempo: ..."; 426 cantidadSpan.textContent = "Resultados: ..."; 427 428 const t0 = performance.now(); 429 const coincidencias = buscarCoincidenciasDesdeSiteMap(texto); 430 const t1 = performance.now(); 431 432 loader.style.display = "none"; 433 434 if (!coincidencias.length) { 435 resultados.innerHTML = renderNoResults(texto); 436 } else { 437 resultados.innerHTML = coincidencias.map(renderResultItem).join(""); 438 } 439 440 tiempoSpan.textContent = `Tiempo: ${(t1 - t0).toFixed(1)} ms`; 441 cantidadSpan.textContent = `Resultados: ${coincidencias.length}`; 442} 443 444function renderResultItem(item) { 445 const meta = getResultTypeMeta(item.Type); 446 const safeUrl = normalizarUrlResultado(item.Url); 447 const urlShown = (() => { 448 const url = item.Url; 449 const currentDomain = window.location.origin; 450 451 if (!url) return ""; 452 453 if (url.startsWith("/")) { 454 return (currentDomain + url).replace(/https?:\/\//, ""); 455 } 456 457 if (url.startsWith("http")) { 458 try { 459 const urlObj = new URL(url); 460 const currentDomainObj = new URL(currentDomain); 461 462 if (urlObj.hostname === currentDomainObj.hostname) { 463 return url.replace(/https?:\/\//, ""); 464 } 465 466 return url; 467 } catch { 468 return url.replace(/https?:\/\//, ""); 469 } 470 } 471 472 return url.replace(/https?:\/\//, ""); 473 })(); 474 475 const titulo = escapeHtml(item.Title || "Sin tÃtulo"); 476 const descripcion = escapeHtml(item.Comments || "Sin comentarios"); 477 const ubicacion = escapeHtml(item.Location || "Portal Web"); 478 const urlVisible = escapeHtml(urlShown); 479 const tipo = escapeHtml(item.Type || "pagina"); 480 const etiquetaTipo = escapeHtml(meta.label); 481 482 return ` 483 <div class="px-4 py-3 hover:bg-gray-100 transition" data-result-type="${tipo}"> 484 <div class="flex gap-3 items-start"> 485 <i class="${meta.icon} ${meta.iconClass} mt-1" aria-hidden="true"></i> 486 487 <div class="min-w-0 flex-1"> 488 <div class="flex flex-wrap items-center gap-2 mb-1"> 489 <span 490 class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${meta.badgeClass}"> 491 ${etiquetaTipo} 492 </span> 493 </div> 494 495 <a 496 class="buscador-link" 497 href="${escapeHtml(safeUrl)}" 498 target="_blank" 499 rel="noopener noreferrer" 500 title="Abrir en nueva pestaña"> 501 ${titulo} 502 </a> 503 504 ${urlVisible ? `<p class="text-[13px] text-blue-500 break-all">${urlVisible}</p>` : ""} 505 506 <p class="text-xs text-gray-600">${descripcion}</p> 507 508 <p class="text-xs text-gray-600"> 509 Proviene de <b>${ubicacion}</b> 510 </p> 511 </div> 512 </div> 513 </div> 514 `; 515} 516 517function getResultTypeMeta(type) { 518 switch (String(type || "").toLowerCase()) { 519 case "documento": 520 return { 521 label: "Documento", 522 icon: "fa-solid fa-file-lines", 523 iconClass: "text-red-600", 524 badgeClass: "bg-red-50 text-red-700", 525 }; 526 527 case "noticia": 528 return { 529 label: "Noticia", 530 icon: "fa-solid fa-newspaper", 531 iconClass: "text-amber-600", 532 badgeClass: "bg-amber-50 text-amber-700", 533 }; 534 535 default: 536 return { 537 label: "Página", 538 icon: "fa-solid fa-globe", 539 iconClass: "text-blue-600", 540 badgeClass: "bg-blue-50 text-blue-700", 541 }; 542 } 543} 544 545function normalizarUrlResultado(url = "") { 546 if (!url) return "#"; 547 548 try { 549 const parsed = new URL(url, window.location.origin); 550 if ( 551 !["http:", "https:"].includes(parsed.protocol) || 552 !isAllowedHost( 553 parsed.hostname 554 ) 555 ) { 556 return "#"; 557 } 558 559 parsed.username = ""; 560 parsed.password = ""; 561 562 return parsed.href; 563 } catch { 564 return "#"; 565 } 566} 567 568function getNoResultPopularSuggestions() { 569 try { 570 const getter = 571 window.__INSTITUTIONAL_SEARCH_INSIGHTS_GET_POPULAR__; 572 573 if ( 574 typeof getter !== 575 "function" 576 ) { 577 return []; 578 } 579 580 const rows = 581 getter(); 582 583 return Array.isArray(rows) 584 ? rows 585 .filter( 586 (row) => 587 row && 588 String( 589 row.term || 590 "" 591 ).trim() 592 ) 593 .slice(0, 5) 594 : []; 595 } catch { 596 return []; 597 } 598} 599 600function renderNoResults(query) { 601 const suggestions = 602 getNoResultPopularSuggestions(); 603 604 const cards = 605 suggestions 606 .map( 607 (row, index) => { 608 const term = 609 String( 610 row.term || 611 "" 612 ).trim(); 613 614 const pageTitle = 615 String( 616 row.pageTitle || 617 "" 618 ).trim(); 619 620 const count = 621 Math.max( 622 1, 623 Number( 624 row.count || 625 1 626 ) || 627 1 628 ); 629 630 return ` 631 <button 632 type="button" 633 class="isearch-noresults-suggestion" 634 data-isearch-noresults-term="${escapeHtml(term)}" 635 title="Buscar ${escapeHtml(term)}">
636 <span class="isearch-noresults-suggestion__rank">${index + 1}</span> 637 <span class="isearch-noresults-suggestion__body"> 638 <span class="isearch-noresults-suggestion__term">${escapeHtml(term)}</span> 639 <span class="isearch-noresults-suggestion__meta"> 640 ${pageTitle ? escapeHtml(pageTitle) : "Búsqueda popular"} 641 </span> 642 </span> 643 <span class="isearch-noresults-suggestion__count">${count}</span> 644 </button> 645 `; 646 } 647 ) 648 .join(""); 649 650 return ` 651 <div class="isearch-noresults-clean" role="status"> 652 <div class="isearch-noresults-clean__head"> 653 <div class="isearch-noresults-clean__icon" aria-hidden="true"> 654 <svg viewBox="0 0 24 24"> 655 <circle cx="11" cy="11" r="7"></circle> 656 <path d="m20 20-3.6-3.6"></path> 657 </svg> 658 </div> 659 660 <div class="isearch-noresults-clean__content"> 661 <div class="isearch-noresults-clean__title"> 662 Sin resultados para â${escapeHtml(query)}â 663 </div> 664 <p class="isearch-noresults-clean__text"> 665 Puedes probar una de estas búsquedas populares. 666 </p> 667 </div> 668 </div> 669 670 ${ 671 cards 672 ? ` 673 <div class="isearch-noresults-suggestions" aria-label="Búsquedas populares sugeridas"> 674 ${cards} 675 </div> 676 ` 677 : "" 678 } 679 </div> 680 `; 681} 682 683const escapeHtml = (s = "") => 684 s.replace( 685 /[&<>"']/g, 686 (m) => 687 ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ 688 m 689 ]) 690 ); 691 692 693 694/* ========================================================================== 695 INSTITUTIONAL_SEARCH_V4_CONSOLIDATED_NO_FOUC 696 ÃNICA CAPA POST-BASE. 697 ========================================================================== */ 698
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.