Como gestionar focus trap en modales con JavaScript en WordPress

Contents

Introducción

Los modales son componentes frecuentes en temas y plugins de WordPress: confirmaciones, formularios rápidos, selectores de medios, etc. Un aspecto crítico que con frecuencia se pasa por alto es la gestión del foco. Un modal accesible debe imitar el comportamiento nativo: cuando se abre, el foco debe ir al modal mientras está abierto, el foco no debe salir del modal (focus trap) al cerrarlo, el foco debe volver al elemento que lo abrió. Este artículo explica, paso a paso, cómo implementar un focus trap robusto con JavaScript, integrarlo en un entorno WordPress y las buenas prácticas de accesibilidad relacionadas.

Conceptos clave

  • Role y atributos ARIA: role=dialog o role=alertdialog, aria-modal=true, aria-labelledby y aria-describedby ayudan a los lectores de pantalla.
  • Focusable elements (elementos focalizables): enlaces, botones, inputs, selects, textareas, elementos con tabindex >= 0 y elementos contenteditable.
  • Focus trap: mantener el foco dentro del modal mientras esté activo, interceptando Tab y Shift Tab.
  • Return focus: devolver el foco al activador original al cerrar el modal.
  • Background inert/oculto: evitar que el contenido detrás del modal sea accesible por lectores de pantalla o teclado (usar inert o aria-hidden cuando sea apropiado).

Markup mínimo recomendado para un modal

Este es el marcado HTML mínimo que se debe usar para que la lógica de JavaScript funcione y para mejorar la accesibilidad.



Selector de elementos focalizables

Para atrapar el foco necesitamos identificar los elementos focalizables dentro del modal. Este selector es el estándar de facto.

const FOCUSABLE_SELECTORS = [
  a[href],
  area[href],
  input:not([disabled]):not([type=hidden]),
  select:not([disabled]),
  textarea:not([disabled]),
  button:not([disabled]),
  iframe,
  object,
  embed,
  [contenteditable],
  [tabindex]:not([tabindex=-1])
].join(, )

Implementación manual de un focus trap (sin librerías)

La estrategia general:

  1. Guardar el elemento activador (el que abre el modal).
  2. Al abrir: hacer visible el modal, aplicar aria-modal, establecer foco inicial (primer elemento focalizable o un elemento con autofocus), y opcionalmente aplicar una clase en el body para bloquear scroll y marcar contenido detrás como inert/oculto.
  3. Escuchar keydown para interceptar Tab y Shift Tab y controlar el ciclo de foco.
  4. Escuchar focusin para forzar el foco dentro del modal si es necesario.
  5. Al cerrar: retirar listeners, ocultar modal, restaurar estado inert/aria-hidden del fondo, y devolver foco al activador guardado.

Ejemplo de implementación ligera

// modal-trap.js
(function () {
  const FOCUSABLE_SELECTORS = [
    a[href],
    area[href],
    input:not([disabled]):not([type=hidden]),
    select:not([disabled]),
    textarea:not([disabled]),
    button:not([disabled]),
    iframe,
    object,
    embed,
    [contenteditable],
    [tabindex]:not([tabindex=-1])
  ].join(, )

  function getFocusableElements(container) {
    return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS))
  }

  function trapFocus(modal, activator) {
    const focusable = getFocusableElements(modal)
    let first = focusable[0]
    let last = focusable[focusable.length - 1]

    function handleKeydown(e) {
      if (e.key === Tab) {
        if (focusable.length === 0) {
          e.preventDefault()
          return
        }
        if (e.shiftKey) {
          // Shift   Tab
          if (document.activeElement === first) {
            e.preventDefault()
            last.focus()
          }
        } else {
          // Tab
          if (document.activeElement === last) {
            e.preventDefault()
            first.focus()
          }
        }
      } else if (e.key === Escape) {
        e.preventDefault()
        closeModal()
      }
    }

    function handleFocusIn(e) {
      if (!modal.contains(e.target)) {
        // Si el foco sale del modal, devolverlo al primer elemento
        first.focus()
      }
    }

    function openModal() {
      modal.removeAttribute(hidden)
      // opcional: document.body.classList.add(modal-open)
      // opcional: aplicar inert al resto del contenido
      document.addEventListener(keydown, handleKeydown)
      document.addEventListener(focusin, handleFocusIn)
      // establecer foco
      if (first) first.focus()
      else modal.focus() // asegúrate de que modal tenga tabindex si no hay elementos focalizables
    }

    function closeModal() {
      modal.setAttribute(hidden, )
      document.removeEventListener(keydown, handleKeydown)
      document.removeEventListener(focusin, handleFocusIn)
      // opcional: document.body.classList.remove(modal-open)
      if (activator  typeof activator.focus === function) activator.focus()
    }

    // Exponer abrir y cerrar
    return { openModal, closeModal }
  }

  // Inicialización ejemplo: buscar activadores con data-modal-target
  document.addEventListener(click, function (e) {
    const trigger = e.target.closest([data-modal-target])
    if (!trigger) return
    const selector = trigger.getAttribute(data-modal-target)
    const modal = document.querySelector(selector)
    if (!modal) return
    const trap = trapFocus(modal, trigger)
    trap.openModal()
    // ejemplo: cerrar con botón dentro del modal
    modal.querySelectorAll(.modal-close).forEach(btn => {
      btn.addEventListener(click, () => trap.closeModal(), { once: true })
    })
  })
})()

Notas sobre la implementación manual

  • Si no hay elementos focalizables dentro del modal, el modal debe recibir tabindex=-1 para que pueda obtener foco.
  • Manejar Escape para cerrar el modal es una buena práctica, pero permitir que el autor decida si se cierra con clic fuera o no.
  • Usar focusin en lugar de focus para capturar cambios de foco que vengan del teclado o del ratón.

Uso de librerías especializadas

Hay librerías robustas que ya implementan focus trap, manejo de pilas de modales, inert y más: por ejemplo focus-trap y inert (polyfill). Para muchos proyectos de WordPress, es preferible usar una librería bien testeada en lugar de una solución casera.

Ejemplo con focus-trap (CDN)

// Usando focus-trap (asumiendo que se cargó focus-trap en la página)
const modalElement = document.querySelector(.modal)
const focusTrapInstance = focusTrap.createFocusTrap(modalElement, {
  onActivate: () => {
    modalElement.removeAttribute(hidden)
    // aplicar inert al resto si es necesario
  },
  onDeactivate: () => {
    modalElement.setAttribute(hidden, )
    // restaurar inert
  },
  escapeDeactivates: true,
  clickOutsideDeactivates: false
})

// Abrir:
focusTrapInstance.activate()
// Cerrar:
focusTrapInstance.deactivate()

Integración con WordPress

En WordPress conviene:

  • Encolar scripts y estilos correctamente con wp_enqueue_script y wp_enqueue_style.
  • Evitar conflictos de nombres en el espacio global (usar IIFE o namespaces).
  • Si el modal forma parte de un bloque de Gutenberg, inicializar el trap cuando el bloque renderiza en el frontend o en el editor según corresponda.
  • Si el modal aparece en el admin, usar los hooks adecuados (admin_enqueue_scripts).

Ejemplo básico de enqueue en un plugin o tema


Control del fondo (inert, aria-hidden, bloqueo de scroll)

Para evitar que el lector de pantallas acceda al contenido detrás del modal y para prevenir interacciones con el fondo:

  • Preferible: usar la propiedad inert (estándar en evolución) y un polyfill para navegadores no compatibles. inert evita tabbable y lectura por lectores de pantalla.
  • Alternativa: aplicar aria-hidden=true al elemento raíz fuera del modal (ej. al main) y removerlo al cerrar.
  • Bloquear scroll: agregar una clase al body como .modal-open { overflow: hidden } o usar position: fixed para evitar saltos de layout.

Ejemplo de aplicar aria-hidden al abrir

function setBackgroundInert(isInert, modal) {
  const appRoot = document.querySelector(#page, main, #wpwrap)  document.body
  if (isInert) {
    appRoot.setAttribute(aria-hidden, true)
    // si usas inert polyfill: appRoot.inert = true
  } else {
    appRoot.removeAttribute(aria-hidden)
    // si usas inert polyfill: appRoot.inert = false
  }
}

Casos avanzados y consideraciones

  • Pilas de modales: si abres un modal desde otro modal debes gestionar una pila: el foco debe regresar al modal anterior al cerrar el superior.
  • Focus dentro de iframes: si el modal contiene iframes, el comportamiento puede ser distinto asegúrate de que los controles dentro del iframe sean accesibles.
  • Contenido dinámico: si cargas contenido por AJAX dentro del modal, volver a calcular los elementos focalizables y reestablecer el foco inicial.
  • Compatibilidad móvil: en móviles el concepto de foco cambia, pero debes mantener la posibilidad de interactuar con inputs y botones. Evita bloquear gestos esenciales.
  • Testing: probar con teclado puro (Tab, Shift Tab, Esc), con lectores de pantalla (NVDA, VoiceOver, TalkBack), y con herramientas de auditoría (axe, Lighthouse).

CSS recomendado (mínimo para accesibilidad visual)

/ modal.css /
.modal[hidden] { display: none }
.modal {
  position: fixed
  left: 50%
  top: 50%
  transform: translate(-50%, -50%)
  z-index: 10000
  background: #fff
  max-width: 90%
  max-height: 90%
  overflow: auto
  padding: 1rem
  box-shadow: 0 10px 30px rgba(0,0,0,0.3)
}
.modal-backdrop {
  position: fixed
  inset: 0
  background: rgba(0,0,0,0.5)
  z-index: 9999
}
body.modal-open {
  overflow: hidden / bloquea scroll cuando modal abierto /
}

Lista de verificación antes de desplegar en producción

  1. El modal tiene role=dialog y aria-modal=true.
  2. Existe aria-labelledby y aria-describedby que apuntan a elementos visibles.
  3. El foco se mueve al modal al abrir y no puede salir mientras está abierto.
  4. Al cerrar, el foco vuelve al activador original.
  5. El contenido detrás está inaccesible para teclado y lectores de pantalla (inert o aria-hidden).
  6. Escape cierra el modal (si la UX lo permite).
  7. Probado con lectores de pantalla y sólo teclado.
  8. Maneja modales apilados correctamente.

Conclusión

Gestionar correctamente el focus trap en modales no es solo una cuestión técnica, es una obligación de accesibilidad que mejora la experiencia para todos los usuarios. En WordPress, la integración correcta pasa por encolar scripts, usar ARIA de forma coherente, aplicar inert/aria-hidden al fondo y considerar usar librerías probadas si el proyecto lo requiere. Con las técnicas descritas en este artículo se obtiene una base sólida para implementar modales accesibles y robustos en cualquier tema o plugin.



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 *