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>
-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 🙂 |
