PageSourceSearch

https://bello.gov.co/Style%20Library/BuscadorInstitucional/buscado…UTIONAL_SEARCH_CONSOLE_CLEAN_1_0_0_20260922

js bello.gov.co collected 2026-09-25 04:16:13 UTC 20,653 bytes, 698 lines download raw bytes

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      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[
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.