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.
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.
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(); }}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.
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.