/** * Accessibility JavaScript * * Mejoras de accesibilidad para navegación por teclado, gestión de focus, * y cumplimiento de WCAG 2.1 Level AA. * * @package ROI_Theme * @since 1.0.0 */ (function() { 'use strict'; /** * Inicializar todas las funciones de accesibilidad */ function init() { setupSkipLinks(); setupKeyboardNavigation(); setupFocusManagement(); setupAriaLiveRegions(); announcePageChanges(); } /** * Skip Links - Navegación rápida al contenido principal * Permite a usuarios de teclado y lectores de pantalla saltar directamente al contenido */ function setupSkipLinks() { const skipLinks = document.querySelectorAll('.skip-link'); skipLinks.forEach(function(link) { link.addEventListener('click', function(e) { const targetId = this.getAttribute('href'); const targetElement = document.querySelector(targetId); if (targetElement) { e.preventDefault(); // Hacer el elemento focusable temporalmente targetElement.setAttribute('tabindex', '-1'); // Enfocar el elemento targetElement.focus(); // Scroll al elemento si está fuera de vista targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Remover tabindex después del focus para no interferir con navegación normal targetElement.addEventListener('blur', function() { targetElement.removeAttribute('tabindex'); }, { once: true }); } }); }); } /** * Navegación por Teclado Mejorada * Maneja la navegación con teclas en menús desplegables y componentes interactivos */ function setupKeyboardNavigation() { // Navegación en menús desplegables de Bootstrap setupBootstrapDropdownKeyboard(); // Navegación en menús personalizados setupCustomMenuKeyboard(); // Escapar de modales con ESC setupModalEscape(); } /** * Navegación por teclado en dropdowns de Bootstrap */ function setupBootstrapDropdownKeyboard() { const dropdownToggles = document.querySelectorAll('[data-bs-toggle="dropdown"]'); dropdownToggles.forEach(function(toggle) { const dropdown = toggle.nextElementSibling; if (!dropdown || !dropdown.classList.contains('dropdown-menu')) { return; } // Abrir dropdown con Enter o Space toggle.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); // Focus en el primer item del menú setTimeout(function() { const firstItem = dropdown.querySelector('a, button'); if (firstItem) { firstItem.focus(); } }, 100); } // Flecha abajo para abrir y enfocar primer item if (e.key === 'ArrowDown') { e.preventDefault(); if (!dropdown.classList.contains('show')) { toggle.click(); } setTimeout(function() { const firstItem = dropdown.querySelector('a, button'); if (firstItem) { firstItem.focus(); } }, 100); } }); // Navegación dentro del dropdown con flechas dropdown.addEventListener('keydown', function(e) { const items = Array.from(dropdown.querySelectorAll('a, button')); const currentIndex = items.indexOf(document.activeElement); if (e.key === 'ArrowDown') { e.preventDefault(); const nextIndex = (currentIndex + 1) % items.length; items[nextIndex].focus(); } if (e.key === 'ArrowUp') { e.preventDefault(); const prevIndex = currentIndex - 1 < 0 ? items.length - 1 : currentIndex - 1; items[prevIndex].focus(); } if (e.key === 'Escape') { e.preventDefault(); toggle.click(); // Cerrar dropdown toggle.focus(); // Devolver focus al toggle } if (e.key === 'Tab') { // Permitir tab normal pero cerrar dropdown toggle.click(); } }); }); } /** * Navegación por teclado en menús personalizados (WordPress) */ function setupCustomMenuKeyboard() { const menuItems = document.querySelectorAll('.menu-item-has-children > a'); menuItems.forEach(function(link) { const parentItem = link.parentElement; const submenu = parentItem.querySelector('.sub-menu'); if (!submenu) { return; } // Inicializar ARIA attributes link.setAttribute('aria-haspopup', 'true'); link.setAttribute('aria-expanded', 'false'); submenu.setAttribute('aria-hidden', 'true'); // Toggle submenu con Enter o Space link.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSubmenu(parentItem, submenu, link); } // Flecha derecha para abrir submenu if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); openSubmenu(parentItem, submenu, link); // Focus en primer item del submenu const firstItem = submenu.querySelector('a'); if (firstItem) { firstItem.focus(); } } // Escape para cerrar submenu if (e.key === 'Escape') { e.preventDefault(); closeSubmenu(parentItem, submenu, link); link.focus(); } }); // Navegación dentro del submenu const submenuItems = submenu.querySelectorAll('a'); submenuItems.forEach(function(item, index) { item.addEventListener('keydown', function(e) { // Flecha arriba/abajo para navegar entre items if (e.key === 'ArrowDown') { e.preventDefault(); const nextItem = submenuItems[index + 1]; if (nextItem) { nextItem.focus(); } } if (e.key === 'ArrowUp') { e.preventDefault(); if (index === 0) { link.focus(); } else { submenuItems[index - 1].focus(); } } // Flecha izquierda para volver al menú padre if (e.key === 'ArrowLeft') { e.preventDefault(); closeSubmenu(parentItem, submenu, link); link.focus(); } // Escape para cerrar submenu if (e.key === 'Escape') { e.preventDefault(); closeSubmenu(parentItem, submenu, link); link.focus(); } }); }); // Cerrar submenu cuando focus sale del elemento parentItem.addEventListener('focusout', function(e) { // Verificar si el nuevo focus está fuera del menú setTimeout(function() { if (!parentItem.contains(document.activeElement)) { closeSubmenu(parentItem, submenu, link); } }, 100); }); }); } /** * Toggle submenu (abrir/cerrar) */ function toggleSubmenu(parentItem, submenu, link) { const isOpen = parentItem.classList.contains('submenu-open'); if (isOpen) { closeSubmenu(parentItem, submenu, link); } else { openSubmenu(parentItem, submenu, link); } } /** * Abrir submenu */ function openSubmenu(parentItem, submenu, link) { parentItem.classList.add('submenu-open'); link.setAttribute('aria-expanded', 'true'); submenu.setAttribute('aria-hidden', 'false'); } /** * Cerrar submenu */ function closeSubmenu(parentItem, submenu, link) { parentItem.classList.remove('submenu-open'); link.setAttribute('aria-expanded', 'false'); submenu.setAttribute('aria-hidden', 'true'); } /** * Cerrar modales con tecla Escape */ function setupModalEscape() { document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { // Bootstrap modals const openModals = document.querySelectorAll('.modal.show'); openModals.forEach(function(modal) { const bsModal = bootstrap.Modal.getInstance(modal); if (bsModal) { bsModal.hide(); } }); // Offcanvas const openOffcanvas = document.querySelectorAll('.offcanvas.show'); openOffcanvas.forEach(function(offcanvas) { const bsOffcanvas = bootstrap.Offcanvas.getInstance(offcanvas); if (bsOffcanvas) { bsOffcanvas.hide(); } }); } }); } /** * Gestión de Focus * Maneja el focus visible y trap de focus en modales */ function setupFocusManagement() { // Mostrar outline solo con navegación por teclado setupFocusVisible(); // Trap focus en modales setupModalFocusTrap(); // Restaurar focus al cerrar modales setupFocusRestore(); } /** * Focus visible solo con teclado (no con mouse) */ function setupFocusVisible() { let usingMouse = false; document.addEventListener('mousedown', function() { usingMouse = true; }); document.addEventListener('keydown', function(e) { if (e.key === 'Tab') { usingMouse = false; } }); // Agregar clase al body para indicar método de navegación document.addEventListener('focusin', function() { if (usingMouse) { document.body.classList.add('using-mouse'); } else { document.body.classList.remove('using-mouse'); } }); } /** * Trap focus dentro de modales (evitar que Tab salga del modal) */ function setupModalFocusTrap() { const modals = document.querySelectorAll('.modal, [role="dialog"]'); modals.forEach(function(modal) { modal.addEventListener('keydown', function(e) { if (e.key !== 'Tab') { return; } // Solo aplicar trap si el modal está visible if (!modal.classList.contains('show') && modal.style.display !== 'block') { return; } const focusableElements = modal.querySelectorAll( 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])' ); if (focusableElements.length === 0) { return; } const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (e.shiftKey) { // Shift + Tab - navegar hacia atrás if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { // Tab - navegar hacia adelante if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }); }); } /** * Restaurar focus al elemento que abrió el modal */ function setupFocusRestore() { let lastFocusedElement = null; // Guardar elemento con focus antes de abrir modal document.addEventListener('show.bs.modal', function(e) { lastFocusedElement = document.activeElement; }); document.addEventListener('show.bs.offcanvas', function(e) { lastFocusedElement = document.activeElement; }); // Restaurar focus al cerrar modal document.addEventListener('hidden.bs.modal', function(e) { if (lastFocusedElement) { lastFocusedElement.focus(); lastFocusedElement = null; } }); document.addEventListener('hidden.bs.offcanvas', function(e) { if (lastFocusedElement) { lastFocusedElement.focus(); lastFocusedElement = null; } }); } /** * ARIA Live Regions * Crear regiones live para anuncios dinámicos a lectores de pantalla */ function setupAriaLiveRegions() { // Crear región live polite si no existe if (!document.getElementById('aria-live-polite')) { const liveRegion = document.createElement('div'); liveRegion.id = 'aria-live-polite'; liveRegion.setAttribute('aria-live', 'polite'); liveRegion.setAttribute('aria-atomic', 'true'); liveRegion.className = 'sr-only'; document.body.appendChild(liveRegion); } // Crear región live assertive si no existe if (!document.getElementById('aria-live-assertive')) { const liveRegion = document.createElement('div'); liveRegion.id = 'aria-live-assertive'; liveRegion.setAttribute('aria-live', 'assertive'); liveRegion.setAttribute('aria-atomic', 'true'); liveRegion.className = 'sr-only'; document.body.appendChild(liveRegion); } } /** * Anunciar cambios de página a lectores de pantalla */ function announcePageChanges() { // Anunciar cambios de ruta en navegación (para SPAs o AJAX) let currentPath = window.location.pathname; // Observer para cambios en el título de la página const titleObserver = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { if (mutation.type === 'childList') { const newTitle = document.title; announce('Página cargada: ' + newTitle, 'polite'); } }); }); const titleElement = document.querySelector('title'); if (titleElement) { titleObserver.observe(titleElement, { childList: true, subtree: true }); } // Detectar navegación por history API const originalPushState = history.pushState; history.pushState = function() { originalPushState.apply(history, arguments); if (window.location.pathname !== currentPath) { currentPath = window.location.pathname; setTimeout(function() { announce('Página cargada: ' + document.title, 'polite'); }, 500); } }; } /** * Función helper para anunciar mensajes a lectores de pantalla * * @param {string} message - Mensaje a anunciar * @param {string} priority - 'polite' o 'assertive' */ window.announceToScreenReader = function(message, priority) { announce(message, priority); }; function announce(message, priority) { priority = priority || 'polite'; const liveRegionId = 'aria-live-' + priority; const liveRegion = document.getElementById(liveRegionId); if (!liveRegion) { return; } // Limpiar y agregar mensaje liveRegion.textContent = ''; setTimeout(function() { liveRegion.textContent = message; }, 100); // Limpiar después de 5 segundos setTimeout(function() { liveRegion.textContent = ''; }, 5000); } /** * Manejar clicks en elementos con data-announce * Permite anunciar acciones a lectores de pantalla */ function setupDataAnnounce() { document.addEventListener('click', function(e) { const target = e.target.closest('[data-announce]'); if (target) { const message = target.getAttribute('data-announce'); const priority = target.getAttribute('data-announce-priority') || 'polite'; announce(message, priority); } }); } /** * Inicializar cuando DOM está listo */ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { init(); setupDataAnnounce(); }); } else { init(); setupDataAnnounce(); } })();