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