- assets → Assets - inc → Inc Completes the case-sensitivity fixes for Linux servers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
546 lines
18 KiB
JavaScript
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();
|
|
}
|
|
|
|
})();
|