También disponible en Inglés
La afirmación a prueba
El ensayo del jueves sostuvo que el navegador no solo renderiza elementos nativos: para un conjunto definido de ellos, también implementa y mantiene el contrato de interacción correspondiente, y el código de la aplicación que reimplementa ese mismo contrato asume una obligación que la plataforma ya estaba cumpliendo. La afirmación específica: un focus trap personalizado y un <dialog> nativo pueden no diferir mucho en un diff, pero difieren en quién es responsable cuando el contenido del modal cambia después de abrirse.
Esta Nota prueba eso directamente. El mismo fallo —nuevo contenido cargándose en un modal abierto— ejecutado contra ambas implementaciones, para ver si la diferencia es real o puramente retórica.
El trampa anterior
La versión personalizada enumera los elementos enfocables del modal una sola vez, al abrirse, y verifica cada pulsación de Tab contra dos de ellos:
function openModal(modal) {
previouslyFocused = document.activeElement;
modal.classList.add('is-open');
const focusable = modal.querySelectorAll(FOCUSABLE_SELECTOR);
first = focusable[0];
last = focusable[focusable.length - 1];
first?.focus();
modal.addEventListener('keydown', trapFocus);
}
function trapFocus(event) {
if (event.key !== 'Tab') return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
first y last no son posiciones. Son referencias a dos nodos del DOM específicos, capturados en el momento en que se abrió el modal.
Ahora se resuelve una solicitud y la lista de opciones se vuelve a renderizar para mostrar la nueva elección:
optionsList.innerHTML = renderOptions(updatedOptions);
innerHTML no actualiza los nodos existentes. Los descarta y construye unos nuevos, incluyendo aquello a lo que apuntaba last. La variable sigue conservando una referencia. El nodo al que se refiere ya no existe en ninguna parte de la página.
document.activeElement nunca podrá volver a ser igual a esa referencia. La próxima vez que un usuario navegue con Tab hacia adelante desde la nueva opción final, la segunda condición de trapFocus fallará en silencio. No se dispara ningún preventDefault(). Nada arroja un error. El orden de tabulación nativo del navegador simplemente toma el control, llevando el foco directamente más allá del modal hacia lo que siga en el documento: una página aún atenuada por una capa superpuesta (overlay) que ya no tiene ningún control real sobre hacia dónde va el foco.
El nuevo contrato
Reconstruye el mismo modal sobre <dialog> y ejecútalo a través del mismo re-render:
<dialog id="preferences">
<form method="dialog">
<div class="options">
<!-- options render here -->
</div>
<button autofocus>Save</button>
</form>
</dialog>
const optionsContainer = dialog.querySelector('.options');
dialog.showModal();
// later, a request resolves
optionsContainer.innerHTML = renderOptions(updatedOptions);
optionsContainer está delimitado a la lista de opciones a propósito: el re-render nunca alcanza al botón Save, que es lo único en este diálogo que no se reevalúa por sí solo. El orden de enfoque secuencial (a lo que responde Tab) se calcula de nuevo en cada pulsación de tecla, razón por la cual la nueva opción se vuelve accesible sin que ningún código note que llegó. autofocus no es ese tipo de mecanismo: el navegador lo busca exactamente una vez, cuando se ejecuta showModal(). Un elemento agregado después de eso, incluso con autofocus definido, necesita una llamada explícita a .focus(); la plataforma no lo captará por sí sola como lo hace con el orden de Tab.
Nada aquí almacena en caché un elemento inicial o final, por lo que nada puede quedar obsoleto. showModal() no calcula una captura instantánea de los descendientes enfocables al momento de abrirse. Hace que todo lo que esté fuera del diálogo sea inerte mientras permanezca abierto, lo que significa que el espacio de búsqueda del orden de Tab del navegador se limita a lo que realmente está dentro del diálogo, evaluado de nuevo en cada pulsación de tecla. Reemplaza el contenido de la lista de opciones a mitad de apertura y la siguiente pulsación de Tab seguirá considerando solo lo que esté actualmente allí, incluida la nueva opción. No hay ninguna referencia que invalidar, porque nunca se tomó ninguna.
Qué cambió de lugar, qué no
| Responsabilidad | Personalizada (Hand-rolled) | <dialog> |
|---|---|---|
| Contención del foco | Referencias en caché, verificadas en cada keydown | Estructural: el fondo es inerte, nada que almacenar en caché |
| Restauración del foco al cerrar | Almacenada y restaurada manualmente | Automática al cerrar, independientemente de cómo se cierre |
| Escape para descartar | Verificación explícita de keydown | Automática |
| Fondo inerte / apilamiento (stacking) |
aria-hidden manual, z-index manual |
Automática: capa superior nativa (native top layer) |
| Objetivo de foco inicial | Llamada imperativa .focus()
|
Declarativa: autofocus, sigue siendo una decisión de autoría |
| Descartar al hacer clic en el fondo | Verificación explícita de clic externo | Sigue siendo explícita: <dialog> no tiene un comportamiento predeterminado para esto |
Cuatro responsabilidades se trasladan directamente a la plataforma. Una pasa de ser una decisión en tiempo de ejecución a una declarativa: sigue siendo decisión de la aplicación, solo que más ligera de llevar. Una no cambia en absoluto: hacer clic fuera de un <dialog> no hace nada por sí solo, y cerrarlo de esa manera aún requiere el mismo tipo de listener que necesitaba la versión personalizada.
Si la afirmación se sostuvo
La afirmación del ensayo El HTML no necesita más reinvención, necesita confianza en la plataforma no era que <dialog> tenga menos errores. Era que reimplementar un contrato que la plataforma ya posee significa asumir la responsabilidad de su corrección indefinidamente, incluyendo casos que el código original nunca anticipó. Este es exactamente ese caso. El trap personalizado no falló por mal código. Falló porque "verificar contra una referencia en caché" es una estrategia con un punto ciego específico y permanente, y un re-render eventualmente lo iba a encontrar.
<dialog> no cierra ese punto ciego con mejor código. Lo cierra al no tener una caché en primer lugar: toda la categoría de fallo a la que estaba expuesta la versión personalizada no se aplica a un mecanismo que reevalúa la capacidad de enfoque en vivo. Esas son las cuatro filas que se trasladaron por completo. Las dos que no lo hicieron —elegir un objetivo de foco inicial y manejar el clic en el fondo— son decisiones específicas de lo que contiene un diálogo dado, no algo que la plataforma pudiera asumir en nombre de la aplicación aunque intentara hacerlo.
Top comments (0)