Procesar modales con div + overlay en Rails suele costarte más que “una simple UI”: terminas con foco que no vuelve al invocador, listeners duplicados al cerrar/reabrir, y atajos de teclado (Escape/Tab) que fallan justo cuando el modal mezcla fetch/Turbo. El resultado típico en apps Turbo-ish es un modal que se abre, se reemplaza su contenido vía Frame, y queda con un estado inconsistente: scroll se bloquea, el lector de pantalla sigue leyendo la página “de fondo” o el foco salta al <body>. La opción nativa —el elemento HTML5 <dialog>— reduce parte de ese estado que hoy recreas a mano.
En esta guía te llevo a un plan que puedes copiar/pegar: una implementación Rails + Hotwire con un dialog reutilizable, gestión explícita de foco (incluido focus trap mínimo) y degradación a overlay solo cuando el navegador no soporta showModal().
El dolor de los modales “div + JS” en Rails: foco, limpieza de listeners y accesibilidad
Un modal con div + overlay en Rails suele volverse una máquina de estados invisible. El primer fallo que te pega es focus return: guardas foco cuando abres, pero cuando el contenido cambia (Turbo Frame update) el invocador puede ser reemplazado o perder identidad en el DOM, y tu “restaurar foco” termina aplicándose sobre un nodo que ya no existe. A eso se suma la limpieza de listeners: si usas addEventListener por apertura sin removeEventListener (o sin disconnect() correcto en Stimulus), cada cierre/reapertura acumula handlers y “duplicas” acciones (por ejemplo, Escape cierra dos veces y deja el UI en un estado parcial).
El segundo grupo de fallos es teclado/semántica. Con overlays personalizados es común que Escape no cierre (o cierre tarde), que Tab salga del modal, o que el scroll lock no se levante en el camino “mal” (por ejemplo: request falla y el modal permanece en un estado que ya no coincide con tu overlay). En apps con Turbo, esto amplifica: en lugar de una navegación completa, reinyectas partes del DOM y el “orden de tab” y los selectores de foco cambian.
Síntomas reproducibles que verás rápido en QA (y que se correlacionan con overlays + estado manual):
- Foco salta al
<body>al abrir o al cerrar tras un update de Turbo Frame. - Escape no cierra cuando el contenido se reemplaza dentro del modal.
- Tab atraviesa elementos de fondo porque el focus trap no se reconstruyó.
- Scroll queda bloqueado aunque el modal ya no sea visible.
- Lector de pantalla anuncia mal porque falta
aria-modal, o porque el backdrop no gestiona accesibilidad.
Mini tabla: señal → causa probable
Señal Causa probable Foco vuelve al lugar incorrecto invocador reemplazado por Turbo; restauración apunta a nodo viejo Escape no cierra listener se agregó solo al open, pero el open ocurrió antes de que el modal contuviera focusables Tab sale del modal focus trap no se recalcula después de cambiar el contenido Duplicación de cierre listener no removido endisconnect(); reopens acumulan handlers
Lo interesante: con <dialog> no desaparece el trabajo de foco (sigue siendo tu responsabilidad), pero sí reduces el “backdrop + modals semantics” que hoy recreas con CSS/JS.
Ahora que ya sabes qué se rompe (y por qué Turbo lo empeora), el siguiente paso es entender exactamente qué hace el navegador por ti cuando usas <dialog>, y qué debes seguir gestionando a propósito.
Contexto de <dialog> en navegadores reales: API showModal/close, estructura y fuente
El elemento <dialog> es un componente nativo del navegador para diálogos modales y no modales. Su API principal es:
- Atributo:
open(refleja si el diálogo está abierto). - Métodos:
dialog.showModal()→ abre el diálogo como modal (bloquea interacciones con el resto de la página y usa semántica modal nativa) (MDN: HTMLDialogElement.showModal).dialog.show()→ abre sin modalidad estricta (no aplica el bloqueo modal en el mismo sentido) (MDN: HTMLDialogElement.show).dialog.close([returnValue])→ cierra el diálogo y dispara el eventoclose(MDN: HTMLDialogElement.close).
- Eventos:
close→ se emite al cerrarse.
Dos diferencias prácticas que importan en producción:
showModal()agrega una capa de comportamiento modal nativo (incluida la interacción con el foco y el “backdrop” de forma controlada por el navegador, cuando aplica). (MDN: Using the dialog element).- El navegador no “te resuelve” tu focus trap. Tienes que mantener retorno de foco y navegación por teclado dentro del contenido.
Setup mínimo (sin librerías) para confirmar el comportamiento
Crea un dialog y usa un script simple para abrir/cerrar.
<!-- HTML (sin Rails aún) -->
<dialog id="confirm_dialog" aria-labelledby="confirm_title">
<h2 id="confirm_title">Confirmar</h2>
<p>¿Continuar?</p>
<form method="dialog">
<button value="cancel" type="submit">Cancelar</button>
<button value="ok" type="submit">OK</button>
</form>
</dialog>
<button id="confirm_open" type="button">Abrir</button>
<script>
const dialog = document.getElementById('confirm_dialog');
const openBtn = document.getElementById('confirm_open');
openBtn.addEventListener('click', () => {
dialog.showModal();
});
dialog.addEventListener('close', () => {
// returnValue viene de <button value="...">
console.log('Dialog closed with:', dialog.returnValue);
});
</script>
Flujo (invocador → dialog → cierre)
flowchart LR
A[Invocador (botón/link)] --> B[dialog.showModal()]
B --> C[Usuario interactúa (teclado/submit)]
C --> D[dialog.close() o close automático]
D --> E[Restaurar foco al invocador]
Antes de meterte a Rails: valida compatibilidad en tu target. La tabla de compatibilidad de MDN te dirá si showModal() está disponible en tus navegadores objetivo (MDN: dialog compatibility).
Ahora que entiendes la API nativa y su semántica, el siguiente paso es convertir este comportamiento en un componente reutilizable dentro de Rails + Hotwire, sin romper foco cuando Turbo actualiza contenido.
Implementación Rails + Hotwire: un modal <dialog> reusable con Stimulus y URIs
En Rails, el enfoque que mejor escala es: un dialog estable (una instancia en DOM), y contenido dinámico inyectado dentro por Turbo (Frame o Stream). Eso te permite manejar foco con selectores estables y evitar “crear/destroy” del nodo modal en cada apertura.
Estructura de vistas (partial) con <dialog>
Crea una partial compartida que renderice el dialog una sola vez. Idealmente colócala en application.html.erb o en el layout, o renderízala en la vista donde necesitas modales pero manteniendo una instancia por página.
<!-- app/views/shared/_dialog.html.erb -->
<dialog
id="app_dialog"
data-controller="dialog"
aria-labelledby="app_dialog_title"
aria-describedby="app_dialog_description"
class="app-dialog">
<div class="app-dialog__body">
<!-- Puedes reemplazar el contenido vía Turbo Frame -->
<turbo-frame id="app_dialog_frame">
<h2 id="app_dialog_title">Cargando…</h2>
<p id="app_dialog_description">Espera un momento.</p>
</turbo-frame>
</div>
<div class="app-dialog__footer">
<button
type="button"
data-dialog-target="closeButton"
class="app-dialog__close">
Cerrar
</button>
</div>
</dialog>
Stimulus controller: abrir/cerrar, guardar/restaurar foco
El controlador hace tres cosas críticas:
- Guarda
this.lastActiveElemental abrir. - Abre con
showModal()(si existe) o fallback con overlay (lo cubrimos más adelante). - Cierra y restaura foco al elemento invocador, incluso si Turbo reescribió parte del DOM.
// app/javascript/controllers/dialog_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["closeButton"];
connect() {
this.dialog = this.element; // <dialog>
this.lastActiveElement = null;
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.dialog.addEventListener("close", this.onClose);
this.dialog.addEventListener("keydown", this.onKeyDown);
this.closeButtonTarget.addEventListener("click", () => this.close());
}
disconnect() {
this.dialog.removeEventListener("close", this.onClose);
this.dialog.removeEventListener("keydown", this.onKeyDown);
}
open(event) {
// A) Guarda foco actual (invocador)
this.lastActiveElement = document.activeElement;
// B) Abre el dialog modal
// showModal solo aplica en modal mode; si no existe, el fallback lo manejamos en otra sección.
if (typeof this.dialog.showModal === "function") {
if (!this.dialog.open) this.dialog.showModal();
} else {
this.dialog.show();
}
// C) Cuando Turbo cargue/actualice el frame, nosotros reposicionamos el foco.
// No asumimos que el contenido existe en este tick.
queueMicrotask(() => {
// Intentar enfocar primero focusable del contenido; si no existe, enfocar heading.
this.focusFirstOrTitle();
});
}
close() {
// close dispara el evento 'close'
if (typeof this.dialog.close === "function") this.dialog.close();
}
onClose() {
// Restaurar foco si el invocador sigue en el DOM.
if (this.lastActiveElement && document.contains(this.lastActiveElement)) {
this.lastActiveElement.focus();
}
}
onKeyDown(e) {
// Escape cierra
if (e.key === "Escape") {
e.preventDefault();
this.close();
return;
}
// Focus trap mínimo
if (e.key === "Tab") {
const focusables = this.getFocusableElements();
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
}
}
}
getFocusableElements() {
// Selección deliberada: evita elementos deshabilitados/ocultos.
return Array.from(
this.dialog.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
).filter((el) => {
const style = window.getComputedStyle(el);
return style.visibility !== "hidden" && style.display !== "none";
});
}
focusFirstOrTitle() {
const focusables = this.getFocusableElements();
if (focusables.length > 0) {
focusables[0].focus();
return;
}
// Redundancia: si no hay focusables, enfoca el título
const title = this.dialog.querySelector('[id="app_dialog_title"]');
if (title) {
if (!title.hasAttribute('tabindex')) title.setAttribute('tabindex', '-1');
title.focus();
}
}
}
Disparador en la vista Rails: usar Turbo Frame dentro del <dialog>
El truco para que no rompas tu controlador es: el botón/link debe provocar el update del turbo-frame dentro del dialog, y al mismo tiempo abrir el dialog.
<!-- Ejemplo en una vista -->
<%= turbo_frame_tag "app_dialog_frame", "" do %>
<!-- (no necesitas nada aquí; el contenido se reemplaza por la respuesta) -->
<% end %>
<%= link_to "Ver detalles",
details_path(record),
data: {
controller: "dialog",
action: "click->dialog#open",
turbo_frame: "app_dialog_frame"
} %>
Esto genera un comportamiento importante: Turbo reemplaza el contenido del frame con la respuesta del endpoint, pero el dialog permanece en el DOM y tu controlador conserva su referencia al elemento.
Si haces submit dentro del modal con Turbo, define la política de cierre. En vez de adivinar, usa un listener explícito en el controlador. Ejemplo: si tu submit termina con éxito, cierra; si falla (422), mantén abierto.
// dentro del controller: agrega en connect()
this.onSubmitEnd = (event) => {
// turbo:submit-end burbujea. Solo cierra si el request fue exitoso.
const { success } = event.detail || {};
if (success) this.close();
};
window.addEventListener("turbo:submit-end", this.onSubmitEnd);
// y en disconnect():
window.removeEventListener("turbo:submit-end", this.onSubmitEnd);
Ahora estás listo para el paso que normalmente se omite: que el foco y los atajos funcionen después del update del frame. El navegador abre; tu app decide el comportamiento correcto para teclado y lectores.
Gestión de accesibilidad: focus trap, retorno de foco y atajos con Escape/Tab
<dialog> te da semántica y un contenedor modal que el navegador entiende, pero tu app aún debe asegurar tres garantías:
- Retorno de foco: al cerrar, el foco vuelve exactamente al invocador si todavía existe en el DOM.
- Focus trap: mientras el diálogo está abierto, Tab y Shift+Tab no deben escapar a la página de fondo.
- Atajos: Escape cierra el modal (o ejecuta una acción equivalente), y el primer foco útil se establece al abrir o tras un update.
En el controlador anterior ya hiciste el “focus trap mínimo” y el manejo de Escape. La parte que suele fallar con Turbo es cuándo enfocar. Si el contenido se reemplaza después de abrir, tu primer intento de focus() puede ocurrir antes de que existan los focusables. En la práctica, enfocar en el microtask inicial ayuda, pero si el frame tarda o llega después, debes reintentar cuando Turbo termina.
Una política simple y efectiva: re-enfocar cuando el turbo:render del frame ocurra, o cuando el frame complete su navegación. Si usas turbo:frame-load, puedes escuchar ese evento y entonces llamar focusFirstOrTitle().
connect() {
// ... lo anterior
this.onFrameLoad = (event) => {
if (event.target && event.target.id === "app_dialog_frame") {
// El contenido ya está en el dialog; vuelve a colocar foco.
this.focusFirstOrTitle();
}
};
window.addEventListener("turbo:frame-load", this.onFrameLoad);
}
disconnect() {
// ... lo anterior
window.removeEventListener("turbo:frame-load", this.onFrameLoad);
}
Para lectores de pantalla, la redundancia práctica que puedes aplicar:
aria-modal="true"(aunque el elemento ya es modal conshowModal()) mejora consistencia cuando tu soporte mezcla herramientas.role="dialog"como redundancia.aria-labelledbyyaria-describedbyapuntando a elementos dentro del diálogo.
Ejemplo en el partial:
<dialog
id="app_dialog"
data-controller="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="app_dialog_title"
aria-describedby="app_dialog_description">
<!-- ... -->
</dialog>
El patrón ARIA de diálogo (autoría WAI-ARIA) recomienda explícitamente gestionar focus y atributos relacionados (WAI-ARIA Authoring Practices: Dialog Modal).
Checklist antes/después (lo que debes poder probar):
- Foco: abre → foco en primer elemento del modal; cierra → foco al invocador.
- Teclado: Tab/Shift+Tab no salen del modal; Escape cierra.
- SR: el lector anuncia diálogo y su título, no “solo una sección de fondo”.
Ahora que el teclado y el foco están bajo control, el siguiente frente realista es navegación: submit, history/back y degradación en browsers sin showModal().
Integración con navegación y degradación: submit, history/back y polyfills opcionales
Hay dos problemas de navegación que aparecen con modales en Rails + Turbo:
- Back mientras el modal está abierto: si el contenido del modal vino vía Turbo Frame, el historial puede incluir entradas que “no representan” el estado visible del dialog.
- Cambios de contenido en el modal: si el invocador o el frame cambian, tu restauración de foco debe sobrevivir a que el nodo anterior sea reemplazado.
Política recomendada para back
El patrón más predecible en apps con modales es: o bien cierras el diálogo antes de navegar, o bien codificas el estado del modal en query params (para que el navegador “sepa” qué es visible). En esta guía, dos opciones de implementación:
- Opción A (simple): cerrar el dialog antes de que Turbo cambie la URL/página.
- Opción B (más integrada): usar
?modal=details&id=...y al cargar esa URL, abrir automáticamente el diálogo.
Para evitar inconsistencias, la opción A suele ser suficiente cuando el modal es “detalle” y no una parte permanente del flujo.
Degradación progresiva: nativo vs overlay
Tu fallback debe hacer una cosa: cuando el browser no soporte HTMLDialogElement o no exista showModal, usas un overlay ligero con focus trap y Escape. No necesitas un polyfill con nombre específico si tu estrategia es branch basado en capabilities.
Aquí está el ejemplo del branching:
// Ejemplo de detección
function canUseDialogModal(dialogEl) {
return !!(window.HTMLDialogElement &&
dialogEl instanceof HTMLDialogElement &&
typeof dialogEl.showModal === "function");
}
export function openDialog(dialogEl) {
if (canUseDialogModal(dialogEl)) {
dialogEl.showModal();
return { mode: "native" };
}
// fallback: overlay + role/aria con un comportamiento equivalente
// (mantén tu focus trap en el controller)
dialogEl.setAttribute("open", "");
dialogEl.classList.add("overlay-fallback-open");
// Asegura que el foco se mueva aquí también.
return { mode: "fallback" };
}
Si tu fallback usa div, no lo conviertas en un duplicado completo del dialog. Mantén el foco trap y retorno de foco idénticos a tu controlador, y limita el overlay a quien lo necesita.
Tabla navegador capabilities → comportamiento
Capability ComportamientoHTMLDialogElement + showModal()
Usa <dialog> modal nativo
HTMLDialogElement sin showModal()
Usa show() + focus trap propio
Sin HTMLDialogElement
Fallback overlay con focus trap propio
Puedes (y deberías) validar compatibilidad con la tabla oficial de MDN (MDN: dialog compatibility).
Ahora que tienes la ruta “nativa” y la ruta “sin soporte”, toca cuantificar el beneficio: menos estado manual, menos listeners, y un plan de medición que puedas ejecutar en dev.
Rendimiento y confiabilidad: qué mejora al usar <dialog> vs overlays personalizados
No hay magia: el costo de un modal no es solo render, es estado (quién abre, quién cierra, qué foco cambia, qué se bloquea/desbloquea, y qué pasa si hay error). En overlays personalizados, ese estado suele repartirse por:
- handlers de click para backdrop,
- listeners de keydown para Escape,
- scroll lock/unlock,
- enfoque inicial y restauración,
- focus trap,
- validación de “quién cierra” (cierre por overlay vs por botón vs por submit).
Con <dialog>, parte de lo “interacción modal/backdrop/semántica” se alinea con el navegador. Resultado práctico: menos superficie de código para manejar inconsistencias de stacking (z-index), scroll lock, y condiciones donde un modal “parece abierto” pero el backdrop no refleja el estado.
Benchmark plan reproducible en tu app
Mide tres cosas que te interesan para decidir en producción:
- Tiempo hasta interacción: desde click invocador hasta que el primer elemento focusable del modal recibe focus.
- Listeners activos: tras 50 aperturas/cierres, verifica que no hay crecimiento de handlers (por ejemplo, con contadores internos del controller o herramientas del navegador).
- Tasa de errores de foco: corre
axe-coreen un flujo de apertura/cierre y registra fallos de foco/teclado.
Tabla de métricas (sin valores; tú las llenas en tu entorno)
Métrica Qué medir Cómo recoger TTIF (open → focus) ms hastafocusFirstOrTitle()
Performance panel + log de timestamps
No listener leaks
callbacks por reapertura
contador en controller o DevTools
Errores de accesibilidad
Tab/Escape/foco
axe-core en flujo repetido
Outline de script de prueba (conceptual):
- Abre el modal desde un botón.
- Espera
turbo:frame-load. - Verifica
document.activeElementpertenece aldialog. - Cierra con Escape.
- Verifica que
document.activeElementvuelve al invocador. - Repite 50 veces.
Ahora el valor es claro: puedes demostrar en tu app que tu implementación tiene menos estados “imposibles” que antes, y que tu foco es determinista.
Limitaciones y cuándo NO usar <dialog>
<dialog> no es un sustituto universal. Hay casos donde un overlay controlado (con focus trap y aria a tu medida) o un componente de UI te dará control mejor.
Umbral 1: contenido dinámico con >N focusables o reordering frecuente
Si tu modal reemplaza contenido muchas veces (p. ej., streaming que reordena la grilla de inputs) y el DOM del modal cambia con alta frecuencia —más de 5 actualizaciones por apertura— tu focus trap y tu “primer focusable” deben recalcularse en cada update. En ese escenario, <dialog> no te da nada extra; tu controlador y tu estrategia de selectores/teclas van a ser el factor dominante.
Umbral 2: navegadores legacy sin showModal() y política de fallback compleja
Si tu audiencia incluye navegadores donde no hay soporte consistente para showModal() y tu fallback overlay implica además scroll locking complejo, rutas de back específicas y múltiples modales simultáneos, el costo puede subir. Si tu fallback cambia más de 2× el tamaño de tu código de modal (comparado con la rama nativa), considera usar un overlay controlado desde el inicio o un enfoque de componentes.
Casos conocidos/edge cases
- Diferencias
show()vsshowModal(): si usasshow()por fallback, no asumas el mismo comportamiento modal de navegador. Si tu requisito es “bloquear interacción con fondo”, implementa esa regla tú. - ifram es / portales dentro del dialog: focusables dentro de iframes no se listan con
querySelectorAlldel documento principal; tu focus trap puede considerar “no hay focusables” y terminar enfocando el título. - Streaming/SSE que reemplaza nodos: si cada mensaje reemplaza inputs y cambia el conjunto de focusables, tu estrategia de “enfocar primero” debe ejecutarse en cada render del frame.
Alternativas recomendadas
- Si necesitas modales con pantallas completas, layout complejo con múltiples paneles y comportamiento de “widget” más allá del patrón modal básico, usa un overlay controlado + focus trap +
aria-modal(o un componente existente) para mantener control. - Si el objetivo es compatibilidad máxima y tu producto requiere un UX muy específico en browsers antiguos, puede ser mejor empezar con overlay controlado y solo usar
<dialog>como mejora.
Sobre compatibilidad y soporte, consulta la tabla de MDN para dialog y showModal() (MDN: dialog compatibility).
Próximos pasos: checklist de implementación y repo de ejemplo para copiar/pegar
Aquí tienes una checklist para que este patrón entre en tu código sin sorpresas:
Checklist (copiar/pegar en tu PR)
- Renderiza un
dialoguna sola vez (idealmente en layout) con unturbo-framedentro. - Conecta un Stimulus controller que:
- guarda
document.activeElementenopen, - abre con
showModal()cuando exista, - cierra con Escape,
- aplica focus trap (Tab/Shift+Tab),
- restaura foco en el evento
close.
- guarda
- Escucha
turbo:frame-load(o render equivalente) para re-enfocar después de inyectar contenido. - Define un comportamiento para submit dentro del modal (por ejemplo: cerrar solo si
turbo:submit-endfue exitoso). - Implementa degradación por capabilities:
- si existe
showModal(), usa modal nativo, - si no, usa overlay/fallback manteniendo el mismo controller de foco.
- si existe
- Prueba el flujo con teclado: Tab/Shift+Tab/Escape y vuelve al invocador.
- Ejecuta una pasada de accesibilidad (axe-core) sobre open/close.
Estructura de repo sugerida
app/
views/
shared/
_dialog.html.erb
javascript/
controllers/
dialog_controller.js
Comandos típicos
- Generar controller (si aún no existe):
bin/rails generate stimulus dialog
- Asegura que Hotwire/Turbo está activo (según tu stack Rails) y que el controlador se registra en tu
application.js/import de Stimulus.
Diagrama final del flujo (con focus management)
flowchart LR
A[Click invocador] --> B[Stimulus#open: guarda foco + showModal()]
B --> C[Turbo Frame carga contenido]
C --> D[Stimulus: turbo:frame-load → focusFirstOrTitle()]
D --> E[Usuario navega con Tab/Shift+Tab dentro del dialog]
E --> F[Escape o acción: close()]
F --> G[dialog 'close' event → restaurar foco al invocador]
Cierra tu implementación con un último objetivo medible: registra timestamps y valida que document.activeElement vuelve al invocador después de cerrar en, al menos, 3 navegadores de tu target. Si ese check pasa, ya no estás “depurando modales”; estás ejecutando un patrón determinista que sobrevivió a Turbo y a teclado real.