Un modal accesible con Portal y Show
Sintetizar Portal y Show en un modal real y accesible: montaje condicional, z-index sobre los contextos de apilamiento, foco inicial, trampa de foco y restauracion, cierre con Escape y con backdrop, y la propagacion de eventos por el arbol de componentes de Solid.
Un modal de verdad es la síntesis de este bloque. Portal para escapar del overflow y del z-index de los ancestros; Show para montarlo y desmontarlo de forma reactiva; y encima, la accesibilidad que separa un modal profesional de un <div> flotante: foco que entra y queda atrapado, Escape que cierra, aria correcto y el matiz de los eventos delegados que cruzan el Portal. Aquí se juntan todas las piezas del nivel.
- Montar y desmontar un modal con
Showdentro de unPortal. - Colocar el overlay sobre cualquier contexto de apilamiento con
z-index. - Gestionar el foco: enfocar al abrir, atraparlo con
Taby restaurarlo al cerrar. - Prever el cierre por
backdropy el evento que sube por el árbol de Solid.
Estructura: Show dentro de Portal
Show decide si el modal existe; Portal decide dónde. El orden importa: Show fuera, Portal dentro, para que cerrar desmonte de verdad el subárbol —y con él sus listeners y su trampa de foco— en lugar de solo ocultarlo. Al desmontar, los onCleanup registrados se disparan y restauran el estado.
import { Show, onMount, onCleanup } from "solid-js";
import { Portal } from "solid-js/web";
function Modal(props: { abierto: boolean; onCerrar: () => void; children: JSX.Element }) {
return (
<Show when={props.abierto}>
<Portal>
<Dialogo onCerrar={props.onCerrar}>{props.children}</Dialogo>
</Portal>
</Show>
);
}
Aislar el cuerpo en un componente Dialogo no es cosmético: su onMount corre justo cuando el diálogo entra en el DOM, y su onCleanup cuando Show lo retira. Ese es el gancho exacto para gestionar el foco sin un solo efecto con lista de dependencias.
Por qué Show y no ocultar con CSS: un display: none deja el subárbol vivo —sus listeners siguen registrados, sus nodos siguen en el orden de tabulación, el lector de pantalla aún los alcanza—. Show lo destruye, y con él desaparecen la trampa de foco, los manejadores y cualquier temporizador. Montar y desmontar no es solo eficiencia: es corrección de accesibilidad.
La heurística habitual es enfocar el primer control interactivo del diálogo; si el contenido es largo o puramente informativo, enfoca el propio contenedor con tabindex="-1" para que el anuncio empiece por el título en vez de saltar a un botón. Un atributo autofocus en un campo concreto gana a la heurística cuando quieres dirigir la atención a una entrada específica.
z-index, foco y Escape
El overlay se posiciona fijo cubriendo la ventana y con un z-index alto. Como el Portal lo montó en body, ese z-index no compite con los contextos de apilamiento de tus paneles: está por encima de todo el layout. Dentro, el diálogo gestiona su ciclo de foco.
function Dialogo(props: { onCerrar: () => void; children: JSX.Element }) {
let caja!: HTMLDivElement;
const previo = document.activeElement as HTMLElement | null;
onMount(() => {
caja.querySelector<HTMLElement>(
"button, [href], input, select, textarea, [tabindex]",
)?.focus();
});
onCleanup(() => previo?.focus()); // devuelve el foco a quien abrio
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") props.onCerrar();
if (e.key === "Tab") atraparFoco(caja, e);
};
return (
<div class="overlay" style={{ "z-index": 1000 }} onClick={props.onCerrar} onKeyDown={onKey}>
<div
ref={caja}
role="dialog"
aria-modal="true"
tabindex="-1"
onClick={(e) => e.stopPropagation()}
>
{props.children}
</div>
</div>
);
}
La trampa de foco mantiene el tabulador dentro del diálogo: al llegar al último elemento, Tab vuelve al primero, y Shift+Tab en el primero salta al último. Es un requisito de accesibilidad, no un adorno.
function atraparFoco(caja: HTMLElement, e: KeyboardEvent) {
const focos = caja.querySelectorAll<HTMLElement>(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
);
if (focos.length === 0) return;
const primero = focos[0];
const ultimo = focos[focos.length - 1];
if (e.shiftKey && document.activeElement === primero) {
e.preventDefault();
ultimo.focus();
} else if (!e.shiftKey && document.activeElement === ultimo) {
e.preventDefault();
primero.focus();
}
}
flowchart LR OPEN[abrir modal] --> SAVE[guardar el foco previo] SAVE --> TRAP[enfocar dentro y atrapar Tab] TRAP --> ESC[Escape o clic en overlay cierra] ESC --> REST[Show desmonta y restaura el foco] style TRAP fill:#89b4fa,color:#11111b style REST fill:#a6e3a1,color:#11111b
Eventos que suben por el árbol de Solid
El cierre por backdrop ilustra la lección del nivel anterior. El onClick del overlay cierra; el onClick del diálogo llama a stopPropagation para que pulsar dentro no cierre. Pero recuerda que el Portal mantiene los eventos delegados en el árbol de componentes: si tu Modal está anidado bajo un componente con su propio onClick —una tarjeta clicable, una fila de tabla— ese ancestro también recibirá los clics del modal, aunque en el DOM el modal cuelgue del body. El stopPropagation del diálogo corta esa cadena y evita activaciones fantasma en los ancestros lógicos.
Hay un segundo cierre que cubrir: el clic fuera con el ratón cierra por el backdrop, pero quien usa el teclado cierra con Escape. Atender ambos caminos —puntero y teclado— no es opcional; un modal que solo se cierra con el ratón queda fuera del alcance de un usuario de teclado. Por eso el diálogo escucha keydown además de vigilar los clics del overlay.
Escuchar Escape en el overlay funciona porque el foco vive dentro y el evento burbujea hasta él; si prefieres máxima robustez, registra el keydown en document dentro de onMount y quítalo en onCleanup. Y para que los lectores de pantalla no vaguen por detrás del modal, marca el resto de la página con aria-hidden="true" o con el atributo inert mientras el diálogo está abierto, restaurándolo al cerrar.
Semántica ARIA y bloqueo de scroll
El role="dialog" y aria-modal="true" avisan al lector de pantalla de que lo de detrás está inerte; añade aria-labelledby apuntando al id del título para que el diálogo se anuncie con nombre al abrirse. Y como el fondo no debe desplazarse mientras el modal está abierto, bloquea el scroll del documento al montar y restáuralo al desmontar —otro onCleanup que se paga solo—:
onMount(() => {
const previo = document.body.style.overflow;
document.body.style.overflow = "hidden";
onCleanup(() => (document.body.style.overflow = previo));
});
Con esto el modal cumple el contrato completo: se anuncia con nombre, atrapa el foco, inertiza el fondo, bloquea el scroll y restaura todo al cerrarse. Nada de esto necesita un framework de modales: son cinco ganchos colgados del montaje y el desmontaje que Show orquesta por ti, cada uno con su onCleanup simétrico. Esa simetría —lo que haces al abrir se deshace al cerrar, en el mismo componente— es lo que evita las fugas de estado global que arrastran los modales mal hechos.
Un modal accesible parece un problema de CSS y termina siendo la prueba de que has entendido los dos planos de Solid. El plano reactivo decide la existencia: Show no oculta, crea y destruye, y por eso puedes colgar toda la gestión de foco de onMount y onCleanup con la certeza de que corren en el instante justo —entrar y salir del DOM— sin un solo efecto con dependencias que vigilar. El plano físico decide la posición: Portal traslada el nodo al body para que ningún overflow, ningún transform y ningún z-index de un ancestro lo aprisione, y así el overlay cubre de verdad la ventana. Y en la costura de ambos planos aparece el detalle que delata a quien solo copió el patrón sin entenderlo: como los eventos delegados siguen el árbol de componentes y no el del DOM, el clic de un botón del modal puede despertar el onClick de una tarjeta que quedó tres niveles más arriba en el árbol lógico. Quien ve el modal como dos coordenadas —existencia reactiva y posición física— coloca el stopPropagation, el foco y el z-index en su sitio a la primera; quien lo ve como un <div> con estilos, depura clics fantasma durante horas.
- Construye
ModalconShowsobrePortaly comprueba que al cerrar el subárbol se desmonta —añade unonCleanupcon unconsole.log—. - Enfoca el primer elemento interactivo al abrir y restaura el foco al elemento que lo abrió al cerrar.
- Implementa
atraparFocoy verifica queTabyShift+Tabnunca salen del diálogo. - Cierra con
Escapey con clic en el overlay; impide el cierre al pulsar dentro constopPropagation. - Anida el
Modalbajo un componente cononClickpropio y observa el clic fantasma; confírmalo resuelto tras elstopPropagation.