Files
roi-theme/Assets/Js/accessibility.js
FrankZamora c6450211a7 fix: Rename Assets/css to Assets/Css, Assets/js to Assets/Js in git
Windows case-insensitive but Linux case-sensitive.
Git was tracking lowercase, causing 404s on server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 12:13:24 -06:00

546 lines
18 KiB
JavaScript

/**
* 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();
}
})();