Como crear un menú accesible con ARIA y teclado en JS en WordPress

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 🙂



Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *