Como crear un modal accesible con JavaScript nativo en WordPress

Contents

Introducción

Un modal (diálogo) bien implementado mejora la experiencia del usuario, pero si no se hace con criterios de accesibilidad puede impedir la navegación a personas que usan lector de pantalla o sólo el teclado. Este tutorial muestra cómo crear un modal accesible usando JavaScript nativo y cómo integrarlo correctamente en WordPress (sin jQuery), con manejo de foco, roles ARIA, y alternativas cuando no existe soporte para la propiedad inert.

Conceptos clave de accesibilidad

  • Rol y propiedades ARIA: usar role=dialog o role=alertdialog y aria-modal=true. Proveer un título con aria-labelledby o un texto con aria-label.
  • Gestión del foco: al abrir, llevar el foco a un elemento significativo dentro del modal al cerrar, restaurarlo al elemento que activó el modal.
  • Trampado de foco (focus trap): evitar que el foco salga del modal mediante Tab y Shift Tab.
  • Acceso por teclado: permitir cerrar con Esc, y que todos los controles sean navegables con Tab.
  • Ocultar el fondo a AT (assistive technologies): marcar el contenido fuera del modal como aria-hidden=true o usar la propiedad inert si está disponible.
  • Preferencias de movimiento: respetar prefers-reduced-motion para animaciones.

Estructura HTML recomendada

Coloca en tu tema (por ejemplo en un template parcial o en un shortcode) el siguiente marcado mínimo. Observa que el botón que abre el modal tiene aria-controls y el modal tiene role y aria-modal:

lt!-- Trigger --gt
ltbutton type=button class=js-open-modal data-modal-id=mi-modalgtAbrir diálogolt/buttongt

lt!-- Modal: por defecto oculto con aria-hidden --gt
ltdiv id=mi-modal class=modal role=dialog aria-modal=true aria-hidden=true aria-labelledby=mi-modal-titlegt
  ltdiv class=modal__panelgt
    lth2 id=mi-modal-titlegtTítulo del diálogolt/h2gt
    ltpgtContenido del modal: formulario, información, etc.lt/pgt
    ltbutton type=button class=js-close-modalgtCerrarlt/buttongt
  lt/divgt
lt/divgt

Estilos CSS esenciales

Incluye reglas básicas para ocultar/mostrar, centrar y una clase para respetar reduced motion. Asegúrate de conservar indicadores de foco visibles.

/ Base: oculto por defecto /
.modal {
  display: none / visualmente oculto /
  position: fixed
  inset: 0
  z-index: 9999
  align-items: center
  justify-content: center
  background: rgba(0,0,0,0.5)
}

/ Panel /
.modal__panel {
  background: #fff
  padding: 1.25rem
  max-width: 90%
  max-height: 90%
  overflow: auto
  border-radius: 4px
  box-shadow: 0 10px 30px rgba(0,0,0,0.2)
}

/ Mostrar /
.modal[aria-hidden=false] {
  display: flex
}

/ Asegurar foco visible /
.modal__panel :focus {
  outline: 3px solid Highlight
  outline-offset: 2px
}

/ Reducir movimiento si el usuario lo prefiere /
@media (prefers-reduced-motion: reduce) {
  .modal__panel {
    transition: none !important
  }
}

JavaScript nativo: apertura, cierre, trap de foco y gestión del fondo

El siguiente script implementa lo siguiente:

  • Abrir y cerrar modal con botones y tecla Esc.
  • Trap de foco dentro del modal (Tab / Shift Tab).
  • Restaurar foco al elemento que abrió el modal.
  • Ocultar el resto del documento a lectores de pantalla marcando los hermanos del modal con aria-hidden, y usar inert si está disponible.
  • Genera un id único si faltase, y maneja múltiples modales en la misma página.
(function () {
  use strict

  // Selección de elementos focusables
  var FOCUSABLE_SELECTORS = [
    a[href]:not([tabindex=-1]):not([inert]),
    area[href]:not([tabindex=-1]):not([inert]),
    input:not([disabled]):not([tabindex=-1]):not([inert]),
    select:not([disabled]):not([tabindex=-1]):not([inert]),
    textarea:not([disabled]):not([tabindex=-1]):not([inert]),
    button:not([disabled]):not([tabindex=-1]):not([inert]),
    iframe:not([tabindex=-1]):not([inert]),
    audio[controls]:not([tabindex=-1]):not([inert]),
    video[controls]:not([tabindex=-1]):not([inert]),
    [contenteditable]:not([tabindex=-1]):not([inert]),
    [tabindex]:not([tabindex=-1]):not([inert])
  ].join(,)

  // Gestiona ocultar el resto del documento a lectores de pantalla
  function setDocumentHidden(modal, hidden) {
    var bodyChildren = Array.prototype.slice.call(document.body.children)
    bodyChildren.forEach(function (child) {
      if (child === modal) return
      if (hidden) {
        child.setAttribute(aria-hidden, true)
      } else {
        child.removeAttribute(aria-hidden)
      }
    })
    // Si el navegador soporta inert, úsalo para bloquear interacciones
    if (inert in HTMLElement.prototype) {
      bodyChildren.forEach(function (child) {
        if (child === modal) return
        child.inert = hidden
      })
    }
  }

  // Encuentra elementos focusables dentro de un contenedor
  function getFocusableElements(container) {
    return Array.prototype.slice.call(container.querySelectorAll(FOCUSABLE_SELECTORS))
  }

  // Genera ID único
  function ensureId(el, prefix) {
    if (!el.id) {
      el.id = prefix   -   Math.random().toString(36).substr(2, 9)
    }
    return el.id
  }

  // Clase que representa un modal manejado
  function Modal(el) {
    this.el = el
    this.isOpen = false
    this.activeTrigger = null
    this.onKeyDown = this.onKeyDown.bind(this)
    this.onFocusIn = this.onFocusIn.bind(this)
  }

  Modal.prototype.open = function (trigger) {
    if (this.isOpen) return
    this.activeTrigger = trigger  document.activeElement
    // Marcar visible
    this.el.setAttribute(aria-hidden, false)
    // Asegurar que el modal tenga un id en su título para aria-labelledby
    var labelled = this.el.getAttribute(aria-labelledby)
    if (!labelled) {
      var panel = this.el.querySelector(.modal__panel)
      if (panel) {
        var title = panel.querySelector(h1,h2,h3,h4)
        if (title) {
          ensureId(title, modal-title)
          this.el.setAttribute(aria-labelledby, title.id)
        }
      }
    }
    // Ocultar resto del documento
    setDocumentHidden(this.el, true)
    // Llevar foco al primer elemento focusable o al panel
    var focusables = getFocusableElements(this.el)
    if (focusables.length) {
      focusables[0].focus()
    } else {
      var panel = this.el.querySelector(.modal__panel)  this.el
      panel.setAttribute(tabindex, -1)
      panel.focus()
    }
    // Agregar listeners globales
    document.addEventListener(keydown, this.onKeyDown)
    document.addEventListener(focusin, this.onFocusIn)
    this.isOpen = true
  }

  Modal.prototype.close = function () {
    if (!this.isOpen) return
    this.el.setAttribute(aria-hidden, true)
    setDocumentHidden(this.el, false)
    document.removeEventListener(keydown, this.onKeyDown)
    document.removeEventListener(focusin, this.onFocusIn)
    if (this.activeTrigger  typeof this.activeTrigger.focus === function) {
      this.activeTrigger.focus()
    }
    this.activeTrigger = null
    this.isOpen = false
  }

  // Evita que el foco salga del modal
  Modal.prototype.onFocusIn = function (event) {
    if (!this.isOpen) return
    if (this.el.contains(event.target)) return
    // Fuerza foco al primer focusable si el foco sale
    var focusables = getFocusableElements(this.el)
    if (focusables.length) {
      focusables[0].focus()
    } else {
      var panel = this.el.querySelector(.modal__panel)  this.el
      panel.focus()
    }
  }

  // Manejo de teclas: Escape y trap Tab
  Modal.prototype.onKeyDown = function (event) {
    if (!this.isOpen) return
    if (event.key === Escape  event.key === Esc) {
      event.preventDefault()
      this.close()
      return
    }
    if (event.key === Tab) {
      var focusables = getFocusableElements(this.el)
      if (focusables.length === 0) {
        // Si no hay focusables, mantener foco en panel
        event.preventDefault()
        var panel = this.el.querySelector(.modal__panel)  this.el
        panel.focus()
        return
      }
      var first = focusables[0]
      var last = focusables[focusables.length - 1]
      if (!event.shiftKey  document.activeElement === last) {
        event.preventDefault()
        first.focus()
      } else if (event.shiftKey  document.activeElement === first) {
        event.preventDefault()
        last.focus()
      }
    }
  }

  // Inicialización: buscar triggers y modales
  function initModals() {
    var modals = {}
    // Registrar todos los modales existentes
    var modalEls = document.querySelectorAll([role=dialog], [role=alertdialog])
    modalEls.forEach(function (el) {
      var id = ensureId(el, modal)
      modals[id] = new Modal(el)
    })

    // Delegación para botones que abren modales
    document.addEventListener(click, function (e) {
      var openBtn = e.target.closest(.js-open-modal)
      if (openBtn) {
        var modalId = openBtn.getAttribute(data-modal-id)
        if (!modalId) return
        var modal = modals[modalId]  document.getElementById(modalId)  (modals[modalId] = new Modal(document.getElementById(modalId)))  modals[modalId]
        if (modal) {
          modal.open(openBtn)
          e.preventDefault()
        }
      }
      var closeBtn = e.target.closest(.js-close-modal)
      if (closeBtn) {
        // Cerrar el modal más cercano
        var modalEl = closeBtn.closest([role=dialog], [role=alertdialog])
        if (modalEl) {
          var id = modalEl.id
          var modal = modals[id]
          if (modal) modal.close()
          e.preventDefault()
        }
      }
    })

    // También cerrar modal si el usuario hace click fuera del panel (opcional)
    document.addEventListener(click, function (e) {
      var openPanel = e.target.closest(.modal__panel)
      var clickedModal = e.target.closest([role=dialog], [role=alertdialog])
      if (clickedModal  !openPanel  clickedModal.getAttribute(aria-hidden) === false) {
        var id = clickedModal.id
        var modal = modals[id]
        if (modal) modal.close()
      }
    })

    // Exponer para depuración (opcional)
    window.__AccessibleModals = modals
  }

  // Inicializar al cargar DOM
  if (document.readyState === loading) {
    document.addEventListener(DOMContentLoaded, initModals)
  } else {
    initModals()
  }
})()

Integración en WordPress

A continuación hay ejemplos para colocar los archivos y encolarlos desde functions.php de tu tema o en un plugin, sin jQuery.

Encolar scripts y estilos (functions.php)

// functions.php (tema o plugin)
function tema_enqueue_modal_assets() {
    // CSS del modal
    wp_enqueue_style(
        tema-modal,
        get_template_directory_uri() . /assets/css/modal.css,
        array(),
        1.0
    )

    // JS del modal (sin jQuery)
    wp_enqueue_script(
        tema-modal-js,
        get_template_directory_uri() . /assets/js/modal.js,
        array(),
        1.0,
        true
    )
}
add_action(wp_enqueue_scripts, tema_enqueue_modal_assets)

Shortcode para insertar modal en contenido

Un shortcode facilita reutilizar el mismo modal en entradas o páginas:

// functions.php
function tema_modal_shortcode(atts, content = null) {
    atts = shortcode_atts(array(
        id => mi-modal,
        title => Título del diálogo,
        button_text => Abrir diálogo
    ), atts, modal)

    // Escapar valores
    id = sanitize_html_class(atts[id])
    title = esc_html(atts[title])
    button_text = esc_html(atts[button_text])

    ob_start()
    ?>
    

    
class=modal role=dialog aria-modal=true aria-hidden=true aria-labelledby=-title>

Uso en el editor (visual o texto):

[modal id=contact-modal title=Contacto button_text=Abrir formulario]
ltformgt... tu formulario ...lt/formgt
[/modal]

Buenas prácticas, pruebas y comprobaciones

  • Probar con sólo teclado: Tab/Shift Tab, Enter/Space para activar botones, Esc para cerrar.
  • Probar con lectores de pantalla (NVDA, VoiceOver) para verificar que el título y el contenido son anunciados y que el fondo queda inaccesible mientras el modal está abierto.
  • Comprobar en móviles: foco, scroll dentro del modal y comportamiento de teclados virtuales.
  • Considerar la experiencia con múltiples modales anidados: normalmente evitar anidar modales si se hace, manejar pila de focos y aria-hidden adecuadamente.
  • Respetar preferencias de movimiento y evitar animaciones largas que impiden el acceso rápido.
  • Evitar esconder interactividad importante detrás de modales mostrar siempre alternativa o contenido accesible.

Consejos adicionales para WordPress

  • Si usas un page builder o bloques de Gutenberg, crea un bloque o un patrón que incluya el marcado del modal para mantener consistencia.
  • Si ofreces formularios dentro del modal (contacto, login), valida accesibilidad del formulario: labels visibles, errores anunciados mediante aria-live, enfoque en el primer error.
  • Si distribuyes como plugin, registra assets con versiones y carga sólo cuando el shortcode o bloque se usa (usar wp_register_script y encolar condicionalmente).

Resumen

Crear un modal accesible con JavaScript nativo implica más que mostrar y ocultar: requiere manejo correcto del foco, roles y propiedades ARIA, bloqueo del contenido de fondo y soporte por teclado. El código mostrado es un punto de partida sólido y modular que puedes adaptar a tu tema o plugin de WordPress. Integra los snippets en archivos separados (modal.js, modal.css) y encola con las APIs de WordPress para mantener buenas prácticas de rendimiento y compatibilidad.



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 *