Segunda ola de implementaciones masivas con agentes paralelos para funcionalidades avanzadas de SEO, accesibilidad y contenido especializado. **Issue #17 - Imágenes responsive con srcset/WebP/AVIF:** - inc/image-optimization.php: 8 nuevas funciones para optimización - Soporte WebP/AVIF con detección de servidor - Srcset y sizes automáticos contextuales - Lazy loading inteligente (excluye LCP) - Threshold 2560px para big images - Picture element con fallbacks - Preload de featured images - Calidad JPEG optimizada (85%) - Dimensiones explícitas (previene CLS) - 14 filtros WordPress implementados - Beneficios: 30-50% reducción con WebP, 50-70% con AVIF - Core Web Vitals: Mejora LCP y CLS **Issue #18 - Accesibilidad WCAG 2.1 AA:** - assets/css/accessibility.css: +461 líneas - Focus styles visibles (3px outline) - Screen reader utilities - Touch targets ≥44px - High contrast mode support - Reduced motion support - Color contrast AA (4.5:1, 3:1) - assets/js/accessibility.js: 19KB nuevo - Skip links con smooth scroll - Navegación por teclado en dropdowns - Arrow keys en menús WordPress - Modal keyboard support - Focus management y trap - ARIA live regions - Announcements para screen readers - header.php: ARIA labels en navbar - Actualizaciones JS: Respeto prefers-reduced-motion en main.js, toc.js, header.js - Cumplimiento completo WCAG 2.1 Level AA **Issue #30 - Tablas APU (Análisis Precios Unitarios):** - assets/css/tables-apu.css: 560 líneas - Diseño sin bordes, moderno - Zebra striping (#f8f9fa/#ffffff) - Headers sticky con degradado azul - 4 tipos de filas: normal, section-header, subtotal, total - Fuente monospace para columnas monetarias - Responsive (scroll horizontal móvil) - Print styles con color-adjust: exact - inc/apu-tables.php: 330 líneas, 6 funciones - apus_process_apu_tables() - Procesamiento automático - Shortcodes: [apu_table], [apu_row type=""] - apus_generate_apu_table($data) - Generación programática - 4 métodos de uso: data-apu, shortcode, clase manual, PHP - docs/APU-TABLES-GUIDE.md: Guía completa de usuario - docs/APU-TABLE-EXAMPLE.html: Ejemplo funcional - 6 columnas: Clave, Descripción, Unidad, Cantidad, Costo, Importe - CRÍTICO: Contenido principal del sitio de construcción **Issue #31 - Botones de compartir en redes sociales:** - inc/social-share.php: 127 líneas - apus_get_social_share_buttons() - Genera HTML - apus_display_social_share() - Template tag - 5 redes: Facebook, X/Twitter, LinkedIn, WhatsApp, Email - URLs nativas sin JavaScript de terceros - Encoding seguro, ARIA labels - assets/css/social-share.css: 137 líneas - Animaciones hover (translateY, scale) - Colores específicos por red - Responsive (576px, 360px) - Focus styles accesibles - single.php: Integración después del contenido - Bootstrap Icons CDN (v1.11.3) - Panel de opciones con configuración **Issue #33 - Schema.org completo (5 tipos):** - inc/schema-org.php: 468 líneas, 7 funciones - Organization schema con logo y redes sociales - WebSite schema con SearchAction - Article schema (posts) con autor, imagen, categorías, wordCount - WebPage schema (páginas) con featured image - BreadcrumbList schema (8 contextos diferentes) - JSON-LD format en <head> - Referencias cruzadas con @id - Google Rich Results compliant - Deshabilita schemas Rank Math/Yoast (evita duplicación) - Locale: es-MX - Hook: wp_head (prioridad 5) **Archivos Modificados:** - functions.php: Includes de nuevos módulos (schema-org, apu-tables, social-share) - inc/enqueue-scripts.php: Enqueue de nuevos CSS/JS, Bootstrap Icons CDN - inc/image-optimization.php: 8 funciones nuevas WebP/AVIF - assets/css/accessibility.css: +461 líneas - assets/js/main.js, toc.js, header.js: Reduced motion support - single.php: Social share buttons - header.php: ARIA labels - inc/admin/options-api.php: Social share settings **Archivos Creados:** - 3 archivos PHP funcionales (apu-tables, social-share, schema-org) - 1 archivo JavaScript (accessibility.js - 19KB) - 3 archivos CSS (tables-apu, social-share) - 2 archivos docs/ (APU guide y example) - 5 reportes .md de documentación **Estadísticas:** - Total funciones nuevas: 30+ - Líneas de código nuevas: 2,500+ - Archivos nuevos: 13 - Archivos modificados: 10 - Mejoras de accesibilidad: WCAG 2.1 AA compliant - Mejoras SEO: 5 schemas JSON-LD - Mejoras performance: WebP/AVIF, lazy loading, srcset 🤖 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 Apus_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();
|
|
}
|
|
|
|
})();
|