PageSourceSearch

https://media2.utp.edu.co/accesibilidad.js?ver=1.0.1

js utp.edu.co collected 2026-09-24 09:02:29 UTC 41,496 bytes, 1,192 lines download raw bytes

1// // Agregar Bootstrap CSS
2// var bootstrapCSS = document.createElement('link');
3// bootstrapCSS.rel = 'stylesheet';
4// bootstrapCSS.href = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css';
5// bootstrapCSS.integrity = 'sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr';
6// bootstrapCSS.crossOrigin = 'anonymous';
7// document.head.appendChild(bootstrapCSS);
8
9// Agregar Bootstrap Icons
10let bootstrapIcons = document.createElement('link');
11bootstrapIcons.rel = 'stylesheet';
12bootstrapIcons.href = 'https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css';
13document.head.appendChild(bootstrapIcons);
14
15// // Agregar Bootstrap JS Bundle (con Popper incluido)
16// var bootstrapJS = document.createElement('script');
17// bootstrapJS.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js';
18// bootstrapJS.integrity = 'sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q';
19// bootstrapJS.crossOrigin = 'anonymous';
20// // Aseguramos que se ejecute después de cargar completamente
21// bootstrapJS.defer = true;
22// document.body.appendChild(bootstrapJS);
23
24; (function () {
25  // VARIABLES GLOBALES
26  const body = document.documentElement
27  const minZoom = 100
28  const maxZoom = 200
29  const step = 20
30
31  const anchoBarra = 750
32  const altoBarra = 10
33  const mitadAncho = anchoBarra / 2
34  const mitadAlto = altoBarra / 2
35  let reglaX = 0
36  let reglaY = 0
37  let mouseObjetivoX = null
38  let mouseObjetivoY = null
39  let animacionRegla = null
40  let y = window.innerHeight / 2
41  let spotlightActive = false
42  let indiceModo = 0
43
44  const modosVisuales = ['', 'modo-lectura', 'alto-contraste']
45  const nombresModos = {
46    '': 'Modo Lectura',
47    'modo-lectura': 'Alto Contraste',
48    'alto-contraste': 'Restablecer Contraste'
49  }
50
51  let lectorActivo = false
52
53  // Variables globales para presentación visual
54  let indicePresentacion = 0
55  const estadosPresentacion = ['', 'texto-estandar', 'texto-enfocado']
56  const nombresPresentacion = {
57    '': 'Texto Estándar',
58    'texto-estandar': 'Texto Enfocado',
59    'texto-enfocado': 'Restablecer Texto'
60  }
61
62  // ESTILOS
63  const cssLink = document.createElement('link')
64  cssLink.rel = 'stylesheet'
65  cssLink.href = 'https://media2.utp.edu.co/accesibilidad.css'
66  document.head.appendChild(cssLink)
67
68  // OVERLAY SPOTLIGHT
69  const overlay = document.createElement('div')
70  overlay.classList.add('overlay')
71  overlay.style.maskImage = 'none'
72  overlay.style.display = 'none'
73  document.body.appendChild(overlay)
74
75  // FUNCIONES GENERALES
76  function getZoom() {
77    const zoomStr = body.style.zoom || '100%'
78    return parseInt(zoomStr) / 100
79  }
80
81  function aplicarZoom(nuevoZoom) {
82    const zoom = Math.max(minZoom, Math.min(maxZoom, nuevoZoom))
83    body.style.zoom = zoom + '%'
84    if (document.body.classList.contains('regla-lectura-activo')) {
85      actualizarPosicionRegla()
86    }
87  }
88
89  function aumentarZoom() {
90    aplicarZoom(getZoom() * 100 + step)
91  }
92
93  function disminuirZoom() {
94    aplicarZoom(getZoom() * 100 - step)
95  }
96
97  function actualizarPosicionRegla(e) {
98    if (e) {
99      mouseObjetivoX = e.clientX
100      mouseObjetivoY = e.clientY
101    }
102
103    if (!animacionRegla) {
104      animacionRegla = requestAnimationFrame(moverReglaSuave)
105    }
106  }
107
108  function moverReglaSuave() {
109    const barra = document.getElementById('reglaLectura')
110    if (barra && mouseObjetivoX !== null && mouseObjetivoY !== null) {
111      const zoom = getZoom()
112      const destinoX = mouseObjetivoX / zoom - mitadAncho
113      const destinoY = mouseObjetivoY / zoom - mitadAlto - 10
114
115      // Interpolación: acercamos el valor actual al destino
116      const velocidad = 0.4 // Puedes ajustar este valor (0.1 = muy lento, 0.5 = rápido)
117      reglaX += (destinoX - reglaX) * velocidad
118      reglaY += (destinoY - reglaY) * velocidad
119
120      barra.style.left = `${reglaX}px`
121      barra.style.top = `${reglaY}px`
122
123      animacionRegla = requestAnimationFrame(moverReglaSuave)
124    } else {
125      animacionRegla = null
126    }
127  }
128
129  function cambiarModoVisual(btn) {
130    indiceModo = (indiceModo + 1) % modosVisuales.length
131    const nuevoModo = modosVisuales[indiceModo]
132
133    // Remover todos los modos de contraste
134    document.body.classList.remove('modo-lectura', 'alto-contraste')
135
136    // Agregar el nuevo modo solo si no es vacío
137    if (nuevoModo) {
138      document.body.classList.add(nuevoModo)
139    }
140    document.getElementById('modo-actual').textContent = nombresModos[nuevoModo]
141
142    document
143      .querySelectorAll('.btn-modo')
144      .forEach((b) => b.classList.remove('activo'))
145    // Solo agregar clase activo si hay un modo aplicado (no en posición 0)
146    if (nuevoModo && btn) {
147      btn.classList.add('activo')
148    }
149
150    // Anunciar el estado solo si el lector está activo
151    if (lectorActivo) {
152      let mensaje
153      if (nuevoModo === 'modo-lectura') {
154        mensaje = 'Modo lectura activado'
155      } else if (nuevoModo === 'alto-contraste') {
156        mensaje = 'Alto contraste activado'
157      } else {
158        mensaje = 'Contraste restablecido'
159      }
160      activarLectorPantalla(mensaje)
161    }
162  }
163
164  function toggleEspaciadoTexto(button) {
165    document.body.classList.toggle('espaciadoTexto')
166    if (button) {
167      const isActive = document.body.classList.contains('espaciadoTexto')
168      button.classList.toggle('activo', isActive)
169      // Anunciar el estado solo si el lector está activo
170      if (lectorActivo) {
171        activarLectorPantalla(
172          'Espaciado de texto ' + (isActive ? 'activado' : 'desactivado')
173        )
174      }
175    }
176  }
177
178  function irA(label) {
179    const el = document.querySelector(label)
180    if (el) el.scrollIntoView({ behavior: 'smooth' })
181  }
182
183  function toggleMarcarEnlaces(button) {
184    document.body.classList.toggle('marcar-enlaces')
185    if (button) {
186      const isActive = document.body.classList.contains('marcar-enlaces')
187      button.classList.toggle('activo', isActive)
188      // Anunciar el estado solo si el lector está activo
189      if (lectorActivo) {
190        activarLectorPantalla(
191          'Marcar enlaces ' + (isActive ? 'activado' : 'desactivado')
192        )
193      }
194    }
195  }
196
197  function toggleMarcarTitulos(button) {
198    document.body.classList.toggle('marcar-titulos')
199    const boton =
200      button || document.querySelector('[onclick*="toggleMarcarTitulos"]')
201    if (boton) {
202      const isActive = document.body.classList.contains('marcar-titulos')
203      boton.classList.toggle('activo', isActive)
204      // Anunciar el estado solo si el lector está activo
205      if (lectorActivo) {
206        activarLectorPantalla(
207          'Marcar títulos ' + (isActive ? 'activado' : 'desactivado')
208        )
209      }
210    }
211  }
212
213  function desactivarAccesibilidad() {
214    if (lectorActivo) {
215      detenerLectorPantalla()
216      activarLectorPantalla(
217        'Desactivando todas las funcionalidades de accesibilidad'
218      )
219    }
220    lectorActivo = false
221    document.body.classList.remove(
222      'modo-lectura',
223      'alto-contraste',
224      'zoom-activo',
225      'regla-lectura-activo',
226      'marcar-enlaces',
227      'marcar-titulos',
228      'espaciadoTexto',
229      'lector-activo',
230      'texto-estandar',
231      'texto-enfocado'
232    )
233    document.querySelector('html').style.zoom = '100%'
234
235    const regla = document.getElementById('reglaLectura')
236    if (regla) regla.remove()
237
238    overlay.style.display = 'none'
239    spotlightActive = false
240
241    // Remover clase activo de todos los botones
242    document.querySelectorAll('.btn-accesibilidad').forEach((btn) => {
243      btn.classList.remove('activo')
244    })
245
246    const contrasteModo = document.getElementById('modo-actual')
247    if (contrasteModo) contrasteModo.textContent = 'Modo Lectura'
248
249    const textoModo = document.getElementById('texto-actual')
250    if (textoModo) textoModo.textContent = 'Texto Estándar'
251
252    indiceModo = 0
253    indicePresentacion = 0
254  }
255
256  function getSpotlightSize() {
257    if (window.innerWidth >= 375 && window.innerWidth < 768) return 100
258    if (window.innerWidth >= 768 && window.innerWidth < 1024) return 150
259    return 200
260  }
261
262  function updateMask() {
263    const SPOTLIGHT_SIZE = getSpotlightSize()
264    const HALF_SPOTLIGHT = SPOTLIGHT_SIZE / 2
265    const zoom = getZoom()
266    const yZoom = y / zoom
267
268    overlay.style.maskImage = `linear-gradient(to bottom, 
269      #000 0%, 
270      #000 ${yZoom - HALF_SPOTLIGHT}px,
271      transparent ${yZoom - HALF_SPOTLIGHT}px,
272      transparent ${yZoom + HALF_SPOTLIGHT}px,
273      #000 ${yZoom + HALF_SPOTLIGHT}px,
274      #000 100%)`
275    overlay.style.webkitMaskImage = overlay.style.maskImage
276  }
277
278  function toggleSpotlight(button) {
279    spotlightActive = !spotlightActive
280    overlay.style.display = spotlightActive ? 'block' : 'none'
281    if (spotlightActive) updateMask()
282    if (button) button.classList.toggle('activo', spotlightActive)
283
284    // Anunciar el estado solo si el lector está activo
285    if (lectorActivo) {
286      activarLectorPantalla(
287        'Enfoque de lectura ' + (spotlightActive ? 'activado' : 'de
287sactivado')
288      )
289    }
290  }
291
292  function toggleModoLectorPantalla(btn) {
293    lectorActivo = !lectorActivo
294    if (btn) btn.classList.toggle('activo', lectorActivo)
295    document.body.classList.toggle('lector-activo', lectorActivo)
296
297    activarLectorPantalla(
298      'Lector de pantalla ' + (lectorActivo ? 'activado' : 'desactivado')
299    )
300  }
301
302  function toggleReglaLectura(button) {
303    const existente = document.getElementById('reglaLectura')
304
305    if (existente) {
306      existente.remove()
307      document.body.classList.remove('regla-lectura-activo')
308      if (button) button.classList.remove('activo')
309      document.removeEventListener('mousemove', actualizarPosicionRegla)
310    } else {
311      const div = document.createElement('div')
312      div.id = 'reglaLectura'
313      div.className = 'regla-lectura'
314      document.body.appendChild(div)
315      document.body.classList.add('regla-lectura-activo')
316      if (button) button.classList.add('activo')
317
318      // Muy importante: aseguramos que la barra esté centrada en el mouse
319      document.addEventListener('mousemove', actualizarPosicionRegla)
320    }
321
322    // Anunciar el estado solo si el lector está activo
323    if (lectorActivo) {
324      const isActive = document.body.classList.contains('regla-lectura-activo')
325      activarLectorPantalla(
326        'Guía de lectura ' + (isActive ? 'activada' : 'desactivada')
327      )
328    }
329  }
330
331  // Cambiar presentación visual (3 estados: por defecto, 80ch, 50ch)
332  function toggleCambiarPresentacionVisual(button) {
333    indicePresentacion = (indicePresentacion + 1) % estadosPresentacion.length
334    const nuevoEstado = estadosPresentacion[indicePresentacion]
335  
336    // Remover todas las presentaciones anteriores
337    document.body.classList.remove('texto-estandar', 'texto-enfocado')
338  
339    // Aplicar el nuevo estado si existe
340    if (nuevoEstado) {
341      document.body.classList.add(nuevoEstado)
342    }
343  
344    // Actualizar botones y texto
345    document.querySelectorAll('.btn-presentacion').forEach((b) => {
346      b.classList.remove('activo')
347  
348      const span = b.querySelector('span')
349      if (span) {
350        span.textContent = nombresPresentacion[nuevoEstado]
351      }
352    })
353  
354    if (nuevoEstado && button) {
355      button.classList.add('activo')
356    }
357  
358    // Anunciar el estado solo si el lector está activo
359    if (lectorActivo) {
360      let mensaje
361  
362      if (nuevoEstado === 'texto-estandar') {
363        mensaje = 'Presentación estándar activada'
364      } else if (nuevoEstado === 'texto-enfocado') {
365        mensaje = 'Presentación enfocada activada'
366      } else {
367        mensaje = 'Presentación de texto restablecida'
368      }
369  
370      activarLectorPantalla(mensaje)
371    }
372  }
373
374  function esVisible(el) {
375    return !!(
376      el.offsetWidth ||
377      el.offsetHeight ||
378      el.getClientRects().length
379    );
380  }
381  
382  function esFocusable(el) {
383    const tabindex = el.getAttribute('tabindex');
384  
385    if (tabindex === '-1') return false;
386    if (el.disabled) return false;
387  
388    // PERMITIR ACORDEONES: Si está dentro de un acordeón/collapse, ignoramos la visibilidad temporal
389    const dentroDeAcordeon = el.closest('.collapse, .accordion-collapse, [aria-expanded]');
390  
391    if (!dentroDeAcordeon && !esVisible(el)) {
392      return false;
393    }
394  
395    return true;
396  }
397  
398  function esEnlaceValido(href) {
399    return (
400      href &&
401      !href.startsWith('#') &&
402      !href.startsWith('mailto:') &&
403      !href.startsWith('tel:') &&
404      !href.startsWith('javascript:')
405    );
406  }
407  
408  function normalizarURL(url) {
409    try {
410      const u = new URL(url, window.location.origin);
411  
412      u.hash = '';
413      u.search = ''; // elimina parámetros
414      u.pathname = u.pathname.replace(/\/$/, '');
415  
416      return u.href;
417    } catch {
418      return null;
419    }
420  }
421  
422  function obtenerEnlacesEnOrdenDeTab() {
423    const elementos = Array.from(document.querySelectorAll('a[href]'));
424  
425    const focusables = elementos.filter(el => {
426      const href = el.getAttribute('href');
427  
428      return esEnlaceValido(href) && esFocusable(el);
429    });
430  
431    const positivos = [];
432    const normales = [];
433  
434    focusables.forEach(el => {
435      const tabindex = parseInt(el.getAttribute('tabindex')) || 0;
436  
437      if (tabindex > 0) {
438        positivos.push({ el, tabindex });
439      } else {
440        normales.push(el);
441      }
442    });
443  
444    positivos.sort((a, b) => a.tabindex - b.tabindex);
445  
446    return [
447      ...positivos.map(item => item.el),
448      ...normales
449    ];
450  }
451  
452  // Detecta si el enlace pertenece a una versión duplicada de un menú móvil/responsivo
453function esMenuDuplicadoResponsivo(el) {
454  // Clases o selectores comunes usados para ocultar o duplicar menús en móviles/escritorio
455  const selectorContenedoresMoviles = [
456    '.d-block.d-lg-none', 
457    '.d-none.d-md-block',
458    '.mobile-nav',
459    '.mobile-menu',
460    '.offcanvas-body', // Si no deseas incluir el menú del offcanvas si ya existe en la página
461    '[aria-hidden="true"]' // Menús responsivos ocultos en ese momento
462  ].join(',');
463
464  // Si el enlace está dentro de un contenedor responsivo secundario, se descarta
465  return Boolean(el.closest(selectorContenedoresMoviles));
466}
467
468function obtenerListaFinal() {
469  const enlaces = obtenerEnlacesEnOrdenDeTab();
470
471  const resultado = [];
472  const enlacesProcesados = new Set();
473  let contador = 1;
474
475  enlaces.forEach(el => {
476    // 1. Omitir si es un duplicado de un menú responsivo
477    if (esMenuDuplicadoResponsivo(el)) return;
478
479    const normalizado = normalizarURL(el.href);
480    if (!normalizado) return;
481
482    // 2. Extraer texto visible o alt
483    const textoVisible = el.textContent?.trim() || el.innerText?.trim() || '';
484    const img = el.querySelector('img');
485    const altImg = img ? (img.getAttribute('alt')?.trim() || '') : null;
486    const esImagen = Boolean(img);
487    const textoFinal = textoVisible || altImg || '';
488
489    // 3. Clave única considerando la posición del elemento en el DOM (o su ruta contenedora)
490    // Esto diferencia un "Inicio" en el Header de un "Inicio" en el Footer
491    const contenedorPadre = el.closest('header, nav, footer, main, aside')?.tagName || 'DOM';
492    const claveUnica = `${normalizado}|${textoFinal.toLowerCase()}|${contenedorPadre}`;
493
494    if (!enlacesProcesados.has(claveUnica)) {
495      enlacesProcesados.add(claveUnica);
496
497      resultado.push({
498        numero: contador++,
499        url: normalizado,
500        texto: textoFinal,
501        alt: altImg,
502        esImagen: esImagen
503      });
504    }
505  });
506
507  return resultado;
508}
509
510  function crearModalEnlacesSiNoExiste() {
511    if (document.getElementById('modal-enlaces')) return;
512  
513    const modalHTML = document.createElement('div');
514    modalHTML.innerHTML = `
515      <div class="modal fade" id="modal-enlaces" tabindex="-1" aria-labelledby="modalEnlacesLabel" aria-hidden="undefined">
516        <div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
517          <div class="modal-content">
518  
519            <div class="modal-header">
520              <div class="d-flex justify-content-between pe-4 w-100">
521                <h2 class="modal-title" id="modalEnlacesLabel">
522                  Enlaces Navegables
523                </h2>
524                <div class="align-items-center btn-group btn-group-sm orden-lista" role="group" aria-label="Orden de enlaces">
525                  <span class="pe-3">Ordenar por: </span>
526                  <button type="button" class="btn active" id="btn-orden-tab">Tabulación</button>
527                  <button type="button" class="btn" id="btn-orden-alfa">A - Z</button>
528                </div>
529              </div>
530              <button type="button" class="btn-close btn-cerrar-modal-enlaces" aria-label="Cerrar"></button>
531            </div>
532  
533            <div class="modal-body">
534              <div id="lista-enlaces"></div>
535            </div>
536  
537            <div class="modal-footer">
538              <button type="button" class="btn btn-secondary btn-cerrar-modal-enlaces">
539                Cerrar
540              </button>
541            </div>
542  
543          </div>
544        </div>
545      </div>
546    `;
547  
548    document.body.appendChild(modalHTML.firstElementChild);
549    document.querySelectorAll('.btn-cerrar-modal-enlaces').forEach(boton => {
550      boton.addEventListener('click', function (e) {
551        e.preventDefault();
552        e.stopPropagation(); // Impide que el clic escale al Offcanvas
553    
554        const modalEl = document.getElementById('modal-enlaces');
555        const modal = bootstrap.Modal.getInstance(modalEl);
556    
557        if (modal) {
558          // 1. Quita el foco del botón específico que recibió el clic
559          if (document.activeElement) {
560            document.activeElement.blur();
561          }
562          
563          // 2. Oculta el modal de forma limpia
564          modal.hide();
565        }
566      });
567    });
568  }
569
570// Mantenemos una copia global de la lista original obtenida
571let listaEnlacesBase = [];
572
573// Función para ordenar y pintar en el DOM
574function renderizarListaEnlaces(lista) {
575  const contenedor = document.getElementById('lista-enlaces');
576  if (!contenedor) return;
577
578  contenedor.innerHTML = '';
579
580  if (!lista.length) {
581    const p = document.createElement('p');
582    p.textContent = 'No se encontraron enlaces navegables.';
583    contenedor.appendChild(p);
584    return;
585  }
586
587  const ol = document.createElement('ol');
588  ol.className = 'lista-enlaces-contenedor';
589
590  lista.forEach(item => {
591    const li = document.createElement('li');
592    li.className = 'lista-enlaces-item';
593
594    const enlace = document.createElement('a');
595    enlace.className = 'lista-enlaces-anchor';
596    enlace.href = item.url;
597    enlace.target = '_blank';
598    enlace.rel = 'noopener noreferrer';
599
600    const textoMostrar = item.texto?.trim() || item.alt?.trim() || '[Sin texto/alt]';
601    const texto = document.createElement('span');
602    texto.className = 'lista-enlaces-texto';
603    texto.textContent = textoMostrar;
604
605    if (item.esImagen || item.alt) {
606      const tagImagen = document.createElement('span');
607      tagImagen.className = 'lista-enlaces-tag-imagen';
608      texto.appendChild(tagImagen);
609    }
610
611    const url = document.createElement('span');
612    url.className = 'lista-enlaces-url';
613    url.textContent = item.url;
614
615    enlace.appendChild(texto);
616    li.appendChild(enlace);
617    ol.appendChild(li);
618  });
619
620  contenedor.appendChild(ol);
621}
622
623// Función principal que abre el modal
624function toggleRenderizarEnlacesModal(button) {
625  crearModalEnlacesSiNoExiste();
626
627  // Guardamos los datos base escaneados en la página
628  listaEnlacesBase = obtenerListaFinal();
629
630  // Escuchadores de eventos para los botones de ordenamiento
631  const btnTab = document.getElementById('btn-orden-tab');
632  const btnAlfa = document.getElementById('btn-orden-alfa');
633
634  btnTab?.addEventListener('click', () => {
635    btnTab.classList.add('active');
636    btnAlfa.classList.remove('active');
637    // Renderiza la lista original por orden de Tab
638    renderizarListaEnlaces(listaEnlacesBase);
639  });
640
641  btnAlfa?.addEventListener('click', () => {
642    btnAlfa.classList.add('active');
643    btnTab.classList.remove('active');
644    
645    // Crea una copia y la ordena alfabéticamente por el texto del enlace
646    const listaAlfabetica = [...listaEnlacesBase].sort((a, b) => {
647      const textoA = (a.texto || a.alt || '').toLowerCase();
648      const textoB = (b.texto || b.alt || '').toLowerCase();
649      return textoA.localeCompare(textoB, undefined, { numeric: true, sensitivity: 'base' });
650    });
651
652    renderizarListaEnlaces(listaAlfabetica);
653  });
654
655  // Render inicial por orden de Tab por defecto
656  renderizarListaEnlaces(listaEnlacesBase);
657
658  // Mostrar modal con la instancia reutilizada
659  const modalEl = document.getElementById('modal-enlaces');
660  const modal = bootstrap.Modal.getOrCreateInstance(modalEl, {
661    backdrop: false,
662    focus: false
663  });
664
665  modal.show();
666  }
667
668  // INICIALIZACIÓN
669  document.addEventListener('mousemove', (e) => {
670    if (!spotlightActive) return
671    y = e.clientY
672    updateMask()
673  })
674
675  function iniciarLectorPantalla() {
676    const texto = document.body.innerText // Lee todo el texto visible
677    activarLectorPantalla(texto)
678  }
679
680  function activarLectorPantalla(texto) {
681    detenerLectorPantalla() // Detiene si ya está leyendo
682    if ('speechSynthesis' in window) {
683      const mensaje = new SpeechSynthesisUtterance()
684      mensaje.text = texto
685      mensaje.lang = 'es-CO' // Español colombiano
686      mensaje.rate = 1 // Velocidad normal
687      mensaje.pitch = 1 // Tono normal
688      mensaje.volume = 1 // Volumen máximo
689
690      mensaje.onerror = (e) => {
691        if (e.error !== 'interrupted') {
692          console.error('Error en lector:', e)
693        }
694      }
695
696      window.speechSynthesis.speak(mensaje)
697    } else {
698      alert('Tu navegador no soporta el lector de pantalla.')
699    }
700  }
701
702  function detenerLectorPantalla() {
703    if ('speechSynthesis' in window) {
704      window.speechSynthesis.cancel()
705    }
706  }
707
708  function toggleLectorPantalla() {
709    if ('speechSynthesis' in window) {
710      if (window.speechSynthesis.speaking) {
711        detenerLectorPantalla()
712      } else {
713        iniciarLectorPantalla()
714      }
715    }
716  }
717
718  document.addEventListener('DOMContentLoaded', function () {
719    const wrapper = document.createElement('div')
720    wrapper.id = 'accesibilidad-widget'
721    wrapper.innerHTML = `
722      <button class="menu-accesibilidad btn btn-primary fs-4" type="button" data-bs-toggle="offcanvas" data-bs-target="#accessibilityCanvas" aria-controls="accessibilityCanvas" aria-label="Abrir opciones de accesibilidad">
723        <i class="bi bi-universal-access"></i>
724      </button>
725      <div class="offcanvas offcanvas-start" data-bs-scroll="true" data-bs-backdrop="false" tabindex="-1" id="accessibilityCanvas" aria-labelledby="accessibilityCanvasLabel">
726        <div class="offcanvas-header">
727          <h2 class="offcanvas-title" id="accessibilityCanvasLabel">Menú de Accesibilidad</h2>
728          <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Cerrar"></button>
729        </div>
730        <div class="offcanvas-body">
731          <h3 class="px-3">Ir a</h3>
732          <div class="btn-group salto-contenido" role="group">
733            <button class="btn-accesibilidad" onclick="irA('header')">Encabezado</button>
734            <button class="btn-accesibilidad" onclick="irA('main')">Contenido</button>
735            <button class="btn-accesibilidad" onclick="irA('footer')">Pie de página</button>
736          </div>
737          <h3 class="px-3">Opciones de Accesibilidad</h3>
738          <div class="alert alert-info mt-2" role="alert">
739            Puedes hacer zoom en la página usando los atajos de tu navegador:<br>
740            <strong>Ctrl +</strong> para acercar, <strong>Ctrl -</strong> para alejar, <strong>Ctrl 0</strong> para restablecer el zoom.<br>
741            (En Mac, usa <strong>⌘ Command</strong> en lugar de Ctrl)
742          </div>
743          <div class="alert alert-warning mt-2" role="alert">
744            <strong>Navegación por teclado:</strong><br>
745            Con el lector activo, usa <strong>Tab</strong> para navegar entre elementos, 
746            <strong>Enter</strong> o <strong>Espacio</strong> para leer el elemento enfocado.
747          </div>
748          <div class="btn-group mb-3 botones-toggle" role="group">
749            <button class="btn-accesibilidad btn-modo" onclick="cambiarModoVisual(this)"><i class="bi bi-circle-half"></i><span id="modo-actual">Modo Lectura</span></button>
750            <button class="btn-accesibilidad" onclick="toggleEspaciadoTexto(this)"><i class="bi bi-type"></i><span>Espaciado Texto</span></button>
751            <button class="btn-accesibilidad" onclick="toggleMarcarEnlaces(this)"><i class="bi bi-type-underline"></i><span>Marcar Enlaces</span></button>
752            <button class="btn-accesibilidad" onclick="toggleMarcarTitulos(this)"><i class="bi bi-type-bold"></i><span>Marcar títulos</span></button>
753            <button class="btn-accesibilidad btn-no-responsive" onclick="toggleReglaLectura(this)"><i class="bi bi-hr"></i><span>Guía de lectura</span></button>
754            <button class="btn-accesibilidad btn-no-responsive" onclick="toggleSpotlight(this)"><i class="bi bi-lightbulb"></i><span>Enfoque de Lectura</span></button>
755            <button class="btn-accesibilidad btn-no-responsive" onclick="toggleModoLectorPantalla(this)"><i class="bi bi-soundwave"></i><span>Lector de Pantalla</span></button>
756            <button class="btn-accesibilidad btn-presentacion" onclick="toggleCambiarPresentacionVisual(this)"><i class="bi bi-text-wrap"></i><span id="texto-actual">Texto estándar</span></button>
757            <button class="btn-accesibilidad" onclick="toggleRenderizarEnlacesModal(this)"><i class="bi bi-text-wrap"></i><span>Listar <br> Enlaces</span></button>
758            <button class="btn-accesibilidad btn-reset" onclick="desactivarAccesibilidad()"><i class="bi bi-x-circle"></i><span>Restablecer opciones</span></button>
759          </div>
760        </div>
761      </div>
762    `
763    document.body.appendChild(wrapper)
764
765    const offcanvasElement = document.getElementById('accessibilityCanvas')
766    const offcanvasInstance =
767      bootstrap.Offcanvas.getOrCreateInstance(offcanvasElement)
768
769    offcanvasElement.addEventListener('shown.bs.offcanvas', () => {
770      offcanvasElement.focus()
771    })
772
773    document.addEventListener('click', function (event) {
774      const isOffcanvasOpen = offcanvasElement.classList.contains('show')
775      const isClickInsideOffcanvas = offcanvasElement.contains(event.target)
776      const isClickOnToggleButton = event.target.closest(
777        '[data-bs-toggle="offcanvas"]'
778      )
779      if (
780        isOffcanvasOpen &&
781        !isClickInsideOffcanvas &&
782        !isClickOnToggleButton
783      ) {
784        offcanvasInstance.hide()
785      }
786    })
787
788    // Listener global para lectura de elementos al hacer clic si el modo lector está activo
789    document.addEventListener('click', function (e) {
790      if (!lectorActivo) return
791
792      // Si el click es en el fondo del menú de accesibilidad, no leer todo el texto
793      if (e.target.classList.contains('offcanvas-body')) return
794
795      const elemento = e.target
796
797      // Si es el botón de buscar (button-addon2) o un hijo de él, no leer aquí (ya tiene su propio listener)
798      if (elemento.closest && elemento.closest('#button-addon2')) return
799
800      // Si es un botón dentro del contenedor de botones toggle, NO leer aquí (solo las funciones toggle)
801      // EXCEPTO el botón "Restablecer opciones" que debe funcionar normalmente
802      if (
803        elemento.closest &&
804        elemento.closest('.botones-toggle') &&
805        elemento.closest('.btn-accesibilidad') &&
806        !elemento.closest('.btn-reset')
807      ) {
808        return
809      }
810
811      // ----------- BLOQUE PARA CAMPOS DE FORMULARIO (INPUT, TEXTAREA, SELECT) ----------- //
812      if (['INPUT', 'TEXTAREA', 'SELECT'].includes(elemento.tagName)) {
813        // Botón submit o similar
814        if (
815          elemento.type === 'submit' ||
816          elemento.type === 'button' ||
817          elemento.type === 'reset'
818        ) {
819          if (elemento.value) {
820            activarLectorPantalla(elemento.value)
821          } else {
822            activarLectorPantalla('Botón')
823          }
824          return
825        }
826
827        // PRIORIDAD: Si el campo tiene error, lee SOLO el error y sal de la función
828        if (elemento.classList.contains('is-invalid')) {
829          const errorDiv = elemento
830            .closest('div')
831            .querySelector('.invalid-feedback')
832          if (errorDiv && errorDiv.innerText) {
833            activarLectorPantalla('Error: ' + errorDiv.innerText)
834            return
835          }
836        }
837
838        // Si NO hay error, lee el label/aria/placeholder/etc como antes:
839        if (elemento.hasAttribute('aria-label')) {
840          activarLectorPantalla(elemento.getAttribute('aria-label'))
841        } else if (elemento.id) {
842          const label = document.querySelector(
843            'label[for="' + elemento.id + '"]'
844          )
845          if (label) {
846            activarLectorPantalla(label.innerText)
847          } else if (elemento.type === 'checkbox') {
848            let mensaje = 'Casilla'
849            if (elemento.name) mensaje += ' ' + elemento.name
850            mensaje += elemento.checked ? ' activada' : ' desactivada'
851            activarLectorPantalla(mensaje)
852          } else if (elemento.placeholder) {
853            activarLectorPantalla(elemento.placeholder)
854          } else if (elemento.name) {
855            activarLectorPantalla('Campo ' + elemento.name)
856          } else {
857            activarLectorPantalla('Campo de formulario')
858          }
859        } else {
860          if (elemento.type === 'checkbox') {
861            let mensaje = 'Casilla'
862            if (elemento.name) mensaje += ' ' + elemento.name
863            mensaje += elemento.checked ? ' activada' : ' desactivada'
864            activarLectorPantalla(mensaje)
865          } else if (elemento.placeholder) {
866            activarLectorPantalla(elemento.placeholder)
867          } else if (elemento.name) {
868            activarLectorPantalla('Campo ' + elemento.name)
869          } else {
870            activarLectorPantalla('Campo de formulario')
871          }
872        }
873        return
874      }
875
876      // Si es un enlace, prevenir la navegación y solo leer
877      if (elemento.tagName === 'A' && elemento.href) {
878        activarLectorPantalla(elemento.innerText)
879        return
880      }
881
882      // Si es un botón, leer su texto
883      if (elemento.tagName === 'BUTTON') {
884        activarLectorPantalla(elemento.innerText)
885        return
886      }
887
888      // Si es una imagen, leer el alt
889      if (elemento.tagName === 'IMG') {
890        activarLectorPantalla(elemento.alt || 'Imagen sin descripción')
891        return
892      }
893
894      // Si no es ninguno de los anteriores, leer el texto del elemento
895      activarLectorPantalla(elemento.innerText)
896    })
897
898    // Accesibilidad para el formulario de búsqueda de Google
899    const inputBuscar = document.getElementById('buscar')
900    if (inputBuscar) {
901      inputBuscar.addEventListener('focus', function () {
902        if (lectorActivo) {
903          activarLectorPantalla('Campo de búsqueda. Escribe tu consulta')
904        }
905      })
906      inputBuscar.addEventListener('click', function () {
907        if (lectorActivo) {
908          activarLectorPantalla('Campo de búsqueda. Escribe tu consulta')
909        }
910      })
911    }
912    const formBuscar = document.getElementById('cse-search-box')
913    if (formBuscar) {
914      formBuscar.addEventListener('submit', function (e) {
915        if (lectorActivo) {
916          const valor = inputBuscar ? inputBuscar.value : ''
917          activarLectorPantalla('Buscando: ' + valor)
918        }
919        // No se previene el envío para mantener el comportamiento normal
920      })
921    }
922
923    const botonBuscar = document.getElementById('button-addon2')
924    if (botonBuscar) {
925      botonBuscar.addEventListener('focus', function () {
926        if (lectorActivo) {
927          activarLectorPantalla('Botón buscar')
928        }
929      })
930      botonBuscar.addEventListener('click', function () {
931        if (lectorActivo) {
932          activarLectorPantalla(
933            'Buscando: ' + (inputBuscar ? inputBuscar.value : '')
934          )
935        }
936        // No se previene el envío, solo se lee antes de enviar
937      })
938    }
939
940    // Inicializar el contenedor principal con ancho 80ch si existe
941    const contenedor = document.getElementById('contenedor-principal')
942    if (contenedor && !contenedor.hasAttribute('data-ancho-presentacion')) {
943      contenedor.setAttribute('data-ancho-presentacion', '80')
944    }
945  })
946
947  // Exportar funciones globales para uso en botones onclick
948  window.irA = irA
949  window.cambiarModoVisual = cambiarModoVisual
950  window.toggleEspaciadoTexto = toggleEspaciadoTexto
951  window.toggleMarcarEnlaces = toggleMarcarEnlaces
952  window.toggleMarcarTitulos = toggleMarcarTitulos
953  window.toggleReglaLectura = toggleReglaLectura
954  window.toggleSpotlight = toggleSpotlight
955  window.toggleModoLectorPantalla = toggleModoLectorPantalla
956  window.desactivarAccesibilidad = desactivarAccesibilidad
957  window.actualizarPosicionRegla = actualizarPosicionRegla
958  window.iniciarLectorPantalla = iniciarLectorPantalla
959  window.toggleLectorPantalla = toggleLectorPantalla
960  window.toggleCambiarPresentacionVisual = toggleCambiarPresentacionVisual
961  window.toggleRenderizarEnlacesModal = toggleRenderizarEnlacesModal
962
963  document.addEventListener('focusin', function (e) {
964    if (!lectorActivo) return
965    const elemento = e.target
966
967    // Ignora el menú de accesibilidad
968    if (elemento.closest('.offcanvas')) return
969
970    // ----------- BLOQUE PARA CAMPOS DE FORMULARIO (INPUT, TEXTAREA, SELECT) ----------- //
971    if (['INPUT', 'TEXTAREA', 'SELECT'].includes(elemento.tagName)) {
972      // Si es un botón (submit, button, reset), lee el value
973      if (
974        elemento.type === 'submit' ||
975        elemento.type === 'button' ||
976        elemento.type === 'reset'
977      ) {
978        if (elemento.value) {
979          activarLectorPantalla(elemento.value)
980        } else {
981          activarLectorPantalla('Botón')
982        }
983        return
984      }
985
986      // PRIORIDAD: Si el campo tiene error, lee SOLO el error y sal de la función
987      if (elemento.classList.contains('is-invalid')) {
988        const errorDiv = elemento
989          .closest('div')
990          .querySelector('.invalid-feedback')
991        if (errorDiv && errorDiv.innerText) {
992          activarLectorPantalla('Error: ' + errorDiv.innerText)
993          return
994        }
995      }
996
997      // Si NO hay error, lee el label/aria/placeholder/etc como antes:
998      if (elemento.hasAttribute('aria-label')) {
999        activarLectorPantalla(elemento.getAttribute('aria-label'))
1000      } else if (elemento.id) {
1001        const label = document.querySelector('label[for="' + elemento.id + '"]')
1002        if (label) {
1003          activarLectorPantalla(label.innerText)
1004        } else if (elemento.type === 'checkbox') {
1005          let mensaje = 'Casilla'
1006          if (elemento.name) mensaje += ' ' + elemento.name
1007          mensaje += elemento.checked ? ' activada' : ' desactivada'
1008          activarLectorPantalla(mensaje)
1009        } else if (elemento.placeholder) {
1010          activarLectorPantalla(elemento.placeholder)
1011        } else if (elemento.name) {
1012          activarLectorPantalla('Campo ' + elemento.name)
1013        } else {
1014          activarLectorPantalla('Campo de formulario')
1015        }
1016      } else {
1017        if (elemento.type === 'checkbox') {
1018          let mensaje = 'Casilla'
1019          if (elemento.name) mensaje += ' ' + elemento.name
1020          mensaje += elemento.checked ? ' activada' : ' desactivada'
1021          activarLectorPantalla(mensaje)
1022        } else if (elemento.placeholder) {
1023          activarLectorPantalla(elemento.placeholder)
1024        } else if (elemento.name) {
1025          activarLectorPantalla('Campo ' + elemento.name)
1026        } else {
1027          activarLectorPantalla('Campo de formulario')
1028        }
1029      }
1030      return // ¡IMPORTANTE! Termina aquí para no leer otras cosas
1031    }
1032    // ----------- FIN BLOQUE PARA CAMPOS DE FORMULARIO ----------- //
1033
1034    // Si el foco está en un enlace con imagen
1035    if (elemento.tagName === 'A') {
1036      const img = elemento.querySelector('img')
1037      if (img && img.alt) {
1038        activarLectorPantalla(img.alt)
1039        return
1040      }
1041      activarLectorPantalla(elemento.innerText)
1042      return
1043    }
1044
1045    // Si el foco está en una imagen sola
1046    if (elemento.tagName === 'IMG') {
1047      activarLectorPantalla(elemento.alt || '')
1048      return
1049    }
1050
1051    // Si es cualquier otro elemento con texto visible
1052    if (elemento && elemento.innerText) {
1053      activarLectorPantalla(elemento.innerText)
1054    }
1055  })
1056
1057  // comienza Validaciones JS de los formularios en sitios web
1058  const form = document.querySelector('.wpcf7-form #formulario-contacto')
1059
1060  if (!form) return
1061
1062  const config = {
1063    identificacion: {
1064      validate: (val) => /^[0-9]{6,}$/.test(val),
1065      popover:
1066        'Ingrese su número de documento sin puntos ni guiones (mínimo 6 dígitos)',
1067      aria: 'Ingrese su número de identificación'
1068    },
1069    nombre: {
1070      validate: (val) => /^[a-zA-ZÀ-ÿ\s]{3,}$/.test(val),
1071      popover: 'Ingrese nombres y apellidos completos (solo letras)',
1072      aria: 'Ingrese sus nombres y apellidos  completos (solo letras)'
1073    },
1074    ciudad: {
1075      validate: (val) => /^[a-zA-ZÀ-ÿ\s]{3,}$/.test(val),
1076      popover: 'Ingrese su ciudad de residencia (solo letras)',
1077      aria: 'Ingrese su ciudad de residencia  (solo letras)'
1078    },
1079    email: {
1080      validate: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
1081      popover: 'Ingrese una dirección de correo electrónico válida',
1082      aria: 'Ingrese su correo electrónico'
1083    },
1084    telefono: {
1085      validate: (val) => /^(?:\+?[0-9]{10,13})$/.test(val),
1086      popover: 'Ingrese su número de telefono (10 dígitos)',
1087      aria: 'Ingrese su número de telefono - 10 dígitos'
1088    },
1089    celular: {
1090      validate: (val) => /^(?:\+?[0-9]{10,13})$/.test(val),
1091      popover: 'Ingrese su número de Celular (10 dígitos)',
1092      aria: 'Ingrese su número de Celular - 10 dígitos'
1093    },
1094    laboratorio: {
1095      validate: (val) => val !== 'Laboratorio',
1096      aria: 'Seleccione el laboratorio'
1097    },
1098    comentario: {
1099      validate: (val) => val.trim().length >= 10,
1100      popover:
1101        'Describa brevemente el motivo de su consulta (mínimo 10 caracteres)',
1102      aria: 'Ingrese su comentario o consulta  (mínimo 10 caracteres)'
1103    },
1104    asunto: {
1105      validate: (val) => val.trim().length >= 10,
1106      popover:
1107        'Describa brevemente el motivo de su consulta (mínimo 10 caracteres)',
1108      aria: 'Ingrese su asunto o consulta (mínimo 10 caracteres)'
1109    },
1110    direccion: {
1111      validate: (val) => /^[a-zA-Z0-9\s#áéíóúÁÉÍÓÚñÑ.-]{10,}$/.test(val),
1112      popover: 'Escriba una dirección válida. Ejemplo: Carrera 10 # 25-30',
1113      aria: 'Ingrese su dirección de residencia completa'
1114    },
1115    curso: {
1116      validate: (val) => val !== '',
1117      popover: 'Debe seleccionar un curso de la lista.',
1118      aria: 'Seleccione el curso de su interés'
1119    },
1120    'datos_personales[]': {
1121      validate: (field) => field.checked
1122    }
1123  }
1124
1125  const fields = form.querySelectorAll('.wpcf7-form-control')
1126
1127  // Añadir eventos y configurar campos válidos
1128  fields.forEach((field) => {
1129    const name = field.name
1130    if (!(name in config)) return
1131
1132    const fieldConfig = config[name]
1133
1134    // Validación en input y blur
1135    field.addEventListener('input', () => validateField(field, fieldConfig))
1136    field.addEventListener('blur', () => validateField(field, fieldConfig))
1137
1138    // Aria-label
1139    if (fieldConfig.aria) {
1140      field.setAttribute('aria-label', fieldConfig.aria)
1141    }
1142
1143    // Popover
1144    if (fieldConfig.popover) {
1145      field.setAttribute('data-bs-toggle', 'popover')
1146      field.setAttribute('data-bs-trigger', 'focus')
1147      field.setAttribute('data-bs-placement', 'right')
1148      field.setAttribute('data-bs-content', fieldConfig.popover)
1149      new bootstrap.Popover(field)
1150    }
1151  })
1152
1153  function validateField(field, config) {
1154    const errorDiv = field.closest('div').querySelector('.invalid-feedback')
1155    const isValid =
1156      typeof config.validate === 'function'
1157        ? config.validate(
1158          field.name === 'datos_personales[]' ? field : field.value
1159        )
1160        : true
1161
1162    if (isValid) {
1163      field.classList.remove('is-invalid')
1164      field.classList.add('is-valid')
1165      if (errorDiv) errorDiv.style.display = 'none'
1166    } else {
1167      field.classList.add('is-invalid')
1168      field.classList.remove('is-valid')
1169      if (errorDiv) errorDiv.style.display = 'block'
1170    }
1171
1172    return isValid
1173  }
1174
1175  // Manejo de errores desde Contact Form 7
1176  document.addEventListener('wpcf7invalid', function () {
1177    const invalidFields = form.querySelectorAll('.wpcf7-not-valid')
1178    invalidFields.forEach((field) => {
1179      const errorDiv = field.closest('div').querySelector('.invalid-feedback')
1180      field.classList.add('is-invalid')
1181      if (errorDiv) errorDiv.style.display = 'block'
1182    })
1183  })
1184
1185  document.addEventListener('wpcf7mailsent', function () {
1186    fields.forEach((field) => {
1187      field.classList.remove('is-invalid', 'is-valid')
1188      const errorDiv = field.closest('div').querySelector('.invalid-feedback')
1189      if (errorDiv) errorDiv.style.display = 'none'
1190    })
1191  })
1192})()

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.