Este tutorial explica de forma detallada y práctica cómo crear un menú accesible en WordPress usando ARIA y manejo por teclado en JavaScript. Incluye la estructura HTML recomendada, reglas ARIA clave, estilos mínimos CSS para visibilidad y un script JavaScript que implementa gestión de foco, comportamiento por teclado (incluyendo flechas, Escape, Home/End y tipeo para búsqueda rápida), apertura/cierre de submenús y consideraciones para integrarlo correctamente en un tema de WordPress.
Contents
Principios básicos y requisitos de accesibilidad
Antes de entrar en código, conviene comprender los principios y requisitos que guiarán la implementación:
- Semántica y roles: usar roles ARIA cuando la semántica HTML no comunique suficiente información a lectores de pantalla (por ejemplo, menubar, menu, menuitem).
- Gestión del foco: un menú manejable por teclado requiere control explícito de tabindex y foco. Aplicaremos el patrón de roving tabindex: sólo un elemento focusable con tabindex=0 y los demás tabindex=-1.
- Estados ARIA: mantener aria-expanded, aria-haspopup y aria-hidden sincronizados con la visibilidad real del submenú.
- Interacciones de teclado: cubrir flechas (arriba/abajo/izquierda/derecha según orientación), Enter/Space, Escape, Home/End y tipeo para búsqueda rápida.
- Compatibilidad progresiva: el menú debe funcionar como enlaces simples sin JavaScript el JS mejora la experiencia (enhancement).
Estructura HTML recomendada
Un menubar horizontal con submenús típicamente tiene una estructura UL/LI. Para accesibilidad, se recomienda usar botones para los controles que abren submenús (evita enlaces vacíos). A continuación un ejemplo base que puedes adaptar al Walker de WordPress o al output de tu tema:
Notas sobre la estructura:
- role=menubar en la lista principal y role=menu en submenús para comunicar la jerarquía.
- role=menuitem en enlaces o botones que activan acciones o navegación.
- aria-haspopup indica que el elemento tiene un submenú aria-expanded indica estado abierto/cerrado.
- aria-hidden en el submenú sincronizado con su visibilidad.
- tabindex inicial: el primer ítem del menubar es tabindex=0, el resto tabindex=-1 (roving tabindex).
Estilos CSS mínimos
Un CSS básico que oculte submenús por defecto y muestre cuando están abiertos. Este CSS también asegura un indicador de foco visible.
.menu, .submenu { list-style: none margin: 0 padding: 0 } .menu > li { display: inline-block position: relative } .submenu { position: absolute left: 0 top: 100% display: none background: #fff min-width: 160px border: 1px solid #ddd } .submenu li { display: block } .has-submenu[aria-expanded=true] > .submenu, .has-submenu button[aria-expanded=true] .submenu { display: block } / Indicador de foco claro / a[role=menuitem], button[role=menuitem] { padding: 8px 12px color: #111 text-decoration: none background: transparent border: none } a[role=menuitem]:focus, button[role=menuitem]:focus { outline: 3px solid #0b84ff outline-offset: 2px }
Comportamiento y mapeo de teclas
Resumen de teclas y comportamiento que implementaremos:
Tecla | Acción |
---|---|
Flecha derecha | Mover al siguiente item en menubar abrir submenu y mover al primer item si elemento tiene submenú. |
Flecha izquierda | Mover al item anterior en menubar cerrar submenu y volver al control padre. |
Flecha abajo | En menubar horizontal: abrir submenu y mover al primer item en submenu: bajar ítem. |
Flecha arriba | En submenu: subir ítem en menubar cerrar/volver si procede. |
Enter / Space | Activar enlace o abrir/cerrar submenu si el control es botón. |
Escape | Cerrar submenu y devolver foco al control padre. |
Home / End | Ir al primer/último item del contenedor actual. |
Tipeo | Tipo-ahead: buscar elemento cuyo texto comienza con la secuencia tipeada. |
Script JavaScript: comportamiento accesible completo
El siguiente script implementa el patrón descrito: inicializa tabindex, sincroniza atributos ARIA, maneja eventos de teclado y ratón, cierra submenús al hacer clic fuera y permite búsqueda por tipeo. Adáptalo al selector de tu menú en WordPress (por ejemplo, .site-nav o #site-navigation).
/ Inicializador de menú accesible: adaptarlo al selector de tu tema / (function () { const navSelector = .site-nav // ajusta si es necesario function initAccessibleMenu(root) { const menubar = root.querySelector([role=menubar]) if (!menubar) return const menuItems = Array.from(menubar.querySelectorAll([role=menuitem])) // Roving tabindex: primero 0, resto -1 menuItems.forEach((item, i) => { item.setAttribute(tabindex, i === 0 ? 0 : -1) }) // Manejadores menubar.addEventListener(keydown, (e) => handleKeydown(e, menubar)) menubar.addEventListener(click, (e) => handleClick(e, menubar)) document.addEventListener(click, (e) => { if (!root.contains(e.target)) closeAll(menubar) }) // Preparar submenús: asignar ids si faltan, aria-hidden const parents = menubar.querySelectorAll(.has-submenu) parents.forEach((li) => { const button = li.querySelector([aria-haspopup=true]) const submenu = li.querySelector([role=menu]) if (!button !submenu) return if (!submenu.id) submenu.id = submenu- Math.random().toString(36).substr(2, 9) button.setAttribute(aria-controls, submenu.id) button.setAttribute(aria-expanded, button.getAttribute(aria-expanded) false) submenu.setAttribute(aria-hidden, button.getAttribute(aria-expanded) === true ? false : true) // mover focus a primer item del submenu cuando se abre submenu.addEventListener(keydown, (ev) => handleSubmenuKeydown(ev, li)) }) // Este objeto ayudará con typeahead menubar._typeahead = { buffer: , lastTime: 0 } } / Manejo de teclas en el menubar / function handleKeydown(event, menubar) { const target = event.target const items = Array.from(menubar.querySelectorAll([role=menuitem])).filter(i => isVisible(i)) const currentIndex = items.indexOf(target) const isParent = target.closest(.has-submenu) !== null const orientation = horizontal // adaptar si necesitas vertical switch (event.key) { case ArrowRight: event.preventDefault() if (orientation === horizontal) { if (isParent) { openSubmenu(target) focusFirstInSubmenu(target) } else { focusNext(items, currentIndex) } } else { focusNext(items, currentIndex) } break case ArrowLeft: event.preventDefault() if (orientation === horizontal) { focusPrev(items, currentIndex) } else { // en vertical, quizá cerrar submenu focusPrev(items, currentIndex) } break case ArrowDown: event.preventDefault() if (isParent) { openSubmenu(target) focusFirstInSubmenu(target) } else { // si está dentro de submenu, deja que el submenuHandlers lo gestionen si no, mover al siguiente focusNext(items, currentIndex) } break case ArrowUp: event.preventDefault() focusPrev(items, currentIndex) break case Home: event.preventDefault() focusIndex(items, 0) break case End: event.preventDefault() focusIndex(items, items.length - 1) break case Escape: event.preventDefault() // cerrar submenu si está dentro de uno const parentLi = target.closest(.has-submenu) if (parentLi) { closeSubmenu(parentLi) const button = parentLi.querySelector([role=menuitem][aria-haspopup]) if (button) setFocus(button) } else { // cerrar todos closeAll(menubar) } break case Enter: case : // Si es un botón que abre submenu, alternar if (target.getAttribute(aria-haspopup) === true) { event.preventDefault() toggleSubmenu(target) } // Si es enlace, se permite navegación por default break default: if (event.key.length === 1) { handleTypeahead(event, menubar) } break } } / Manejo de teclas dentro de un submenu específico / function handleSubmenuKeydown(event, parentLi) { const submenu = parentLi.querySelector([role=menu]) const items = Array.from(submenu.querySelectorAll([role=menuitem])).filter(i => isVisible(i)) const target = event.target const index = items.indexOf(target) switch (event.key) { case ArrowDown: event.preventDefault() focusNext(items, index) break case ArrowUp: event.preventDefault() focusPrev(items, index) break case Home: event.preventDefault() focusIndex(items, 0) break case End: event.preventDefault() focusIndex(items, items.length - 1) break case Escape: event.preventDefault() closeSubmenu(parentLi) const button = parentLi.querySelector([aria-haspopup=true]) if (button) setFocus(button) break case ArrowLeft: // En submenus, ArrowLeft puede cerrar y devolver al padre en menubars horizontales event.preventDefault() closeSubmenu(parentLi) const parentButton = parentLi.querySelector([aria-haspopup=true]) if (parentButton) setFocus(parentButton) break default: if (event.key.length === 1) { // typeahead en submenu: implementa si lo necesitas // aquí dejamos que el navegador lo procese o implementar similar a handleTypeahead } break } } / Click handler: abrir o seguir enlace según el objetivo / function handleClick(event, menubar) { const target = event.target if (target.getAttribute target.getAttribute(aria-haspopup) === true) { // botón toggle event.preventDefault() toggleSubmenu(target) } // Si es enlace normal, se navega y se cierran menús if (target.tagName === A) { closeAll(menubar) } } / Helpers: abrir/cerrar submenus, focus management / function openSubmenu(button) { const li = button.closest(.has-submenu) if (!li) return const submenu = li.querySelector([role=menu]) li.setAttribute(aria-expanded, true) if (button) button.setAttribute(aria-expanded, true) if (submenu) submenu.setAttribute(aria-hidden, false) } function closeSubmenu(li) { if (!li) return const button = li.querySelector([aria-haspopup=true]) const submenu = li.querySelector([role=menu]) li.setAttribute(aria-expanded, false) if (button) button.setAttribute(aria-expanded, false) if (submenu) submenu.setAttribute(aria-hidden, true) // mover tabindex de los items del submenu a -1 if (submenu) { Array.from(submenu.querySelectorAll([role=menuitem])).forEach(i => i.setAttribute(tabindex, -1)) } } function closeAll(menubar) { const parents = menubar.querySelectorAll(.has-submenu) parents.forEach(li => { li.setAttribute(aria-expanded, false) const button = li.querySelector([aria-haspopup=true]) const submenu = li.querySelector([role=menu]) if (button) button.setAttribute(aria-expanded, false) if (submenu) submenu.setAttribute(aria-hidden, true) if (submenu) Array.from(submenu.querySelectorAll([role=menuitem])).forEach(i => i.setAttribute(tabindex, -1)) }) } function toggleSubmenu(button) { const isExpanded = button.getAttribute(aria-expanded) === true if (isExpanded) { const li = button.closest(.has-submenu) closeSubmenu(li) } else { // cerrar otros submenus del mismo nivel const root = button.closest([role=menubar]) closeAll(root) openSubmenu(button) } } / Focus helpers: roving tabindex / function setFocus(el) { if (!el) return const root = el.closest([role=menubar]) document const items = Array.from(root.querySelectorAll([role=menuitem])) items.forEach(i => i.setAttribute(tabindex, -1)) el.setAttribute(tabindex, 0) el.focus() } function focusNext(items, currentIndex) { const next = items[(currentIndex 1) % items.length] if (next) setFocus(next) } function focusPrev(items, currentIndex) { const idx = (currentIndex - 1 items.length) % items.length const prev = items[idx] if (prev) setFocus(prev) } function focusIndex(items, index) { const el = items[index] if (el) setFocus(el) } function focusFirstInSubmenu(control) { const li = control.closest(.has-submenu) if (!li) return const submenu = li.querySelector([role=menu]) if (submenu) { const first = submenu.querySelector([role=menuitem]) if (first) { // hacer focus y ajustar tabindex Array.from(submenu.querySelectorAll([role=menuitem])).forEach(i => i.setAttribute(tabindex, -1)) first.setAttribute(tabindex, 0) first.focus() } } } function isVisible(el) { return el.offsetParent !== null } / Typeahead sencillo: agrupa pulsaciones rápidas y busca ítems por texto / function handleTypeahead(event, menubar) { const now = Date.now() const ta = menubar._typeahead if (now - ta.lastTime > 700) ta.buffer = ta.lastTime = now ta.buffer = event.key.toLowerCase() const items = Array.from(menubar.querySelectorAll([role=menuitem])).filter(i => isVisible(i)) const match = items.find(i => i.textContent.trim().toLowerCase().startsWith(ta.buffer)) if (match) setFocus(match) } / Inicialización automática al DOMContentLoaded / document.addEventListener(DOMContentLoaded, function () { const root = document.querySelector(navSelector) if (root) initAccessibleMenu(root) }) })()
Integración en WordPress
Recomendaciones prácticas para integrar este patrón en tu tema o plugin de WordPress:
- Salida HTML: Modifica el walker o el template del menú para imprimir la estructura ARIA (role, aria-haspopup, botones para toggles de submenu). Evita emplear enlaces vacíos para controles.
- Enqueue del JS: Añade el script a través de wp_enqueue_script en functions.php, con dependencia de jquery si lo necesitas o sin dependencia si es vanilla JS.
- JS no inline: evita scripts inline usa un archivo .js para facilitar caché y mantenimiento.
- Clases/Selectors: adapta el selector navSelector en el script al CSS de tu tema (por ejemplo #site-navigation o .main-navigation).
- Pruebas: prueba con teclado, lectores de pantalla (NVDA, VoiceOver) y validadores de accesibilidad.
/ Ejemplo básico para enqueue en functions.php / function tema_enqueue_accessible_menu() { wp_enqueue_script( tema-accessible-menu, get_template_directory_uri() . /js/accessible-menu.js, array(), 1.0.0, true ) } add_action(wp_enqueue_scripts, tema_enqueue_accessible_menu)
Pruebas y comprobaciones finales
Antes de publicar, realiza estas comprobaciones:
- Recorrer todo el menú sólo con teclado: Tab se centra en el primer control, luego usar flechas y comprobar que focus y tabindex se actualizan correctamente.
- Comprobar con lector de pantalla que los estados aria-expanded/aria-hidden se anuncian cuando se abren/cerran submenús.
- Comprobar que sin JavaScript el menú sigue siendo usable: enlaces deben navegar correctamente idealmente el CSS permite desplegar submenús con :focus-within si quieres soporte básico sin JS.
- Probar en móviles: los controles por toque deben abrir y cerrar submenús el botón que abre submenú debe ser suficientemente grande y tener aria labels si es necesario.
Conclusión técnica
Crear un menú accesible en WordPress requiere combinar buena semántica HTML, atributos ARIA correctos, estilos CSS claros y un JavaScript que gestione foco y teclado. El patrón del roving tabindex, junto con aria-expanded/aria-hidden y el manejo completo de teclas (flechas, Escape, Home/End y typeahead) proporciona una experiencia consistente para usuarios de teclado y de tecnologías asistivas. Integra el comportamiento en tu theme mediante un script enqueueado y, siempre que sea posible, genera atributos ARIA desde el servidor (walker) para que el HTML sea correcto desde la carga inicial.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |