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.