Enunciado y contexto aplicable
Construye un cuadro de diálogo modal reutilizable para una entrevista técnica de interfaz de usuario frontend. Quien lo invoque proporciona un título, una descripción corta opcional, contenido para el cuerpo, el estado de apertura y callbacks de confirmación y cierre. El diálogo debe admitir usuarios de mouse, pantalla táctil, teclado y tecnologías de asistencia. Se requiere centrado visual y un fondo atenuado (backdrop), pero el problema principal es el comportamiento: el contenido fuera del diálogo activo no está disponible, el diálogo tiene un nombre útil, el foco entra y permanece dentro de él, cada usuario tiene una forma explícita de cerrarlo y el foco regresa a un lugar lógico posteriormente.
Asume que los navegadores modernos estándar admiten la API nativa de dialog. Explica también cómo se implementaría el contrato cuando un entrevistador prohíbe explícitamente el elemento nativo, una webview incrustada carece de soporte o una biblioteca de componentes existente ya posee una primitiva personalizada probada. La unidad reutilizable no debe codificar de forma fija un único destino de foco inicial porque un formulario, un diálogo informativo extenso y una confirmación de eliminación irreversible necesitan opciones diferentes.
La implementación no incluye la obtención de datos específicos de la aplicación, el diseño de animaciones ni un administrador de modales completo. Esos se convierten en preguntas de seguimiento. Los criterios de aceptación son observables: activación por teclado, ubicación del foco, contención del foco, asignación de nombres accesibles, comportamiento de cierre, restauración del foco, superposición de capas y comportamiento estable ante rerenderizados y desmontajes.
Qué evalúa el entrevistador
La primera señal es si el candidato define "modal" como un contrato de comportamiento. Un panel centrado con un valor alto de apilamiento es solo una superposición (overlay). Un verdadero modal hace que el resto del documento sea inerte, contiene la secuencia de tabulación, expone la semántica de diálogo y gestiona el ciclo de vida completo del foco. Agregar un atributo ARIA sin hacer que el contenido externo no esté disponible crea un árbol de accesibilidad engañoso.
La segunda señal es el criterio sobre la plataforma. El elemento nativo dialog abierto con showModal() entra en la capa superior (top layer) del navegador, crea un backdrop, vuelve inertes otros elementos en el mismo documento y proporciona el comportamiento modal de teclado. Abrirlo con show() o estableciendo su atributo open es no modal. Una respuesta sólida utiliza la primitiva nativa cuando el contrato de navegadores del producto lo permite, al tiempo que es capaz de enunciar cada invariante que una implementación personalizada debe reproducir.
La tercera señal es la política de foco en lugar de una regla memorizada de "enfocar el primer botón". Un formulario corto puede enfocar su primer campo no válido o principal. Un diálogo extenso similar a un documento a menudo debería enfocar un encabezado estático con un destino de foco programático para que el inicio y la estructura semántica permanezcan disponibles. Una confirmación irreversible debería enfocar inicialmente la acción menos destructiva. Al cerrar, el foco generalmente regresa al elemento que lo abrió; si ese elemento fue eliminado, quien lo invocó debe elegir el siguiente elemento de trabajo lógico.
La cuarta señal es la corrección del ciclo de vida en el framework. El estado imperativo del navegador y el estado declarativo de la UI no deben desfasarse. Llamar a showModal() dos veces, eliminar un diálogo abierto antes de sincronizar el estado, cerrarlo sin actualizar al padre o restaurar el foco en un nodo obsoleto causa errores reales. Los listeners de eventos necesitan limpieza, las etiquetas únicas deben permanecer estables y el renderizado en el servidor no debe llamar a una API del navegador durante el renderizado.
La señal final es la calidad de la verificación. Las comprobaciones automatizadas de accesibilidad pueden detectar nombres faltantes o atributos no válidos, pero no pueden probar que el foco caiga en el elemento correcto para este flujo de trabajo, que Tab y Shift+Tab permanezcan contenidos, que Escape siga la política del producto o que el foco regrese a un lugar lógico después de que el disparador desaparezca. Esos puntos requieren comprobaciones con teclado y lectores de pantalla.
Preguntas para clarificar antes de responder
- ¿Es verdaderamente modal? Si los usuarios deben seguir interactuando con la página, utiliza un diálogo no modal, un popover o un panel en línea. No lo etiquetes como modal simplemente porque flota sobre el contenido.
- ¿Puedo usar el elemento nativo dialog? En caso afirmativo, utiliza
showModal()y preserva su comportamiento predeterminado. En caso negativo, implementa la semántica, la inerticidad externa, la contención del foco, la tecla Escape y la restauración explícitamente. - ¿Qué contenido aparece dentro? Un formulario simple, un documento estructurado extenso y una alerta requieren diferentes descripciones accesibles y opciones de foco inicial.
- ¿Se puede revertir la operación? Para eliminación o pago, enfoca Cancelar u otro control que sea el menos destructivo. Para un diálogo de continuación rutinario, la acción siguiente probable puede ser adecuada.
- ¿Cómo se puede cerrar? Proporciona siempre un control visible de cierre o cancelación. Clarifica el uso de Escape, el clic en el backdrop, el envío exitoso y si el contenido no guardado requiere un paso de confirmación.
- ¿Qué debe recibir el foco después de cerrar? Generalmente el disparador que lo abrió. Si la creación elimina o reemplaza ese nodo, identifica un sucesor lógico estable antes de escribir el componente.
- ¿Se pueden apilar los diálogos? Es preferible tener un solo modal activo. Si se requiere apilamiento, solo el diálogo superior gestiona el cierre, y cerrarlo restaura el foco dentro del diálogo debajo de él.
- ¿Qué navegadores y tecnologías de asistencia están dentro del alcance? Esto decide si el elemento nativo es suficiente, si necesita una capa de compatibilidad o si debe reemplazarse por una primitiva establecida y probada.
Estructura de respuesta en 30 segundos
“Definiría seis invariantes: un nombre accesible, fondo inerte, foco inicial deliberado, una secuencia de tabulación contenida, cierre explícito y mediante Escape, y restauración lógica del foco. En navegadores modernos utilizaría el elemento nativo dialog con showModal() para la capa superior, el backdrop, la inerticidad y el comportamiento fundamental del foco. El foco inicial sigue la tarea: un encabezado para contenido extenso, el campo relevante para un formulario y Cancelar para una acción irreversible. En React sincronizaría el estado controlado con efectos protegidos de apertura y cierre, escucharía directamente el evento cancel y verificaría la apertura por teclado, ambas direcciones de tabulación, el cierre, la nomenclatura para lectores de pantalla, la restauración del foco, los rerenderizados y los diálogos apilados”.
Análisis paso a paso a fondo
Comienza con invariantes que son independientes de React, CSS o del elemento nativo:
OPEN
exactly one active modal owns interaction
outside content is visually obscured and behaviorally inert
dialog has an accessible name from a visible title
focus is inside the dialog on a purposefully selected target
WHILE OPEN
Tab and Shift+Tab cannot enter the background document
visible close or cancel control is reachable
only the top modal handles a close request
CLOSE
parent open state and browser dialog state agree
focus returns to the opener if it exists
otherwise focus moves to a predefined logical successorA continuación, elige la primitiva. El diálogo nativo es la opción predeterminada para un contrato de navegadores modernos. Llamar a showModal() lo coloca en la capa superior, le otorga un backdrop y vuelve inerte el resto del contenido en su documento. Esto evita tener que recorrer manualmente cada elemento enfocable, establecer aria-hidden en las raíces de la aplicación y luchar con los contextos de apilamiento. Llamar a show() o establecer open produce un diálogo no modal, por lo que no son atajos intercambiables.
Un diálogo personalizado sigue siendo válido cuando en la entrevista se prohíbe el elemento nativo o el producto ya utiliza una primitiva de componentes madura. Debe renderizar un contenedor con semántica de diálogo y un nombre accesible, establecer semántica modal solo cuando la interacción exterior esté realmente bloqueada, hacer inertes todas las raíces de fondo, contener el foco, manejar Escape, restaurar el foco y renderizarse por encima de los contextos de apilamiento de la aplicación. Renderizar el panel mediante un portal al cuerpo del documento ayuda con el recorte y el apilamiento, pero un portal por sí solo no suministra ninguno de esos comportamientos de accesibilidad.
Utiliza una tabla de decisión de foco inicial:
- Para un formulario corto, enfoca el primer campo en el que el usuario deba actuar, especialmente un campo no válido después de la validación.
- Para texto extenso, listas o tablas, enfoca el título u otro elemento estático al inicio con un destino de foco programático. No aplanes una estructura rica en una sola descripción accesible extensa.
- Para operaciones irreversibles, enfoca Cancelar o la acción menos destructiva.
- Para una simple confirmación de lectura o continuación, enfoca la acción más probable siempre que hacerlo no pueda desencadenar un daño accidental.
El nombre accesible normalmente debe hacer referencia al título visible. Una descripción corta en texto plano puede referenciarse por separado. Omite una única referencia de descripción para contenido que contenga varios párrafos, listas o tablas para que los usuarios de tecnologías de asistencia puedan navegar por esa estructura. Un icono de cierre aún necesita un nombre accesible, y debe existir un control visible de cierre o cancelación incluso cuando se admita Escape.
El siguiente ejemplo de React mantiene el estado nativo del navegador sincronizado con el estado controlado de la aplicación. Su prop description es intencionalmente una cadena corta; el contenido complejo va en children y no se asigna como una única descripción aplanada.
'use client'
import { useEffect, useId, useRef, type ReactNode } from 'react'
interface AccessibleModalProps {
open: boolean
title: string
description?: string
initialFocus: 'heading' | 'cancel' | 'confirm'
children: ReactNode
onConfirm: () => void
onOpenChange: (open: boolean) => void
}
export function AccessibleModal({
open,
title,
description,
initialFocus,
children,
onConfirm,
onOpenChange,
}: AccessibleModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const headingRef = useRef<HTMLHeadingElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
const confirmRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
const descriptionId = useId()
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open && !dialog.open) {
dialog.showModal()
const target =
initialFocus === 'confirm'
? confirmRef.current
: initialFocus === 'cancel'
? cancelRef.current
: headingRef.current
target?.focus()
} else if (!open && dialog.open) {
dialog.close()
}
}, [initialFocus, open])
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
const handleCancel = (event: Event) => {
event.preventDefault()
onOpenChange(false)
}
const handleClose = () => {
if (open) onOpenChange(false)
}
dialog.addEventListener('cancel', handleCancel)
dialog.addEventListener('close', handleClose)
return () => {
dialog.removeEventListener('cancel', handleCancel)
dialog.removeEventListener('close', handleClose)
}
}, [onOpenChange, open])
return (
<dialog
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
>
<h2 ref={headingRef} id={titleId} tabIndex={-1}>
{title}
</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
{children}
<div>
<button ref={cancelRef} type="button" onClick={() => onOpenChange(false)}>
Cancel
</button>
<button ref={confirmRef} type="button" onClick={onConfirm}>
Confirm
</button>
</div>
</dialog>
)
}Las condiciones de guardia alrededor de dialog.open evitan llamadas imperativas duplicadas durante los rerenderizados. El listener directo de cancel es importante porque el evento es cancelable y no burbujea. Prevenir su cierre predeterminado permite que el estado controlado cambie primero; el siguiente efecto cierra el diálogo nativo. El listener de close también repara el estado si se ejecuta otra vía de cierre nativa. Las API del navegador permanecen dentro de los efectos, de modo que el renderizado en el servidor solo emite marcado.
Mantén explícita la política de cierre. Un clic en el backdrop no es automáticamente equivalente a Cancelar. Un formulario con trabajo no guardado puede ignorar los clics en el backdrop, un selector ligero puede aceptarlos y una confirmación irreversible no debe desaparecer por un evento de puntero accidental. Si el producto acepta el cierre por clic en el backdrop, utiliza una prueba de impacto (hit test) comprobada en la región del backdrop y canalízala a través de la misma ruta de cierre controlado. Una comprobación exclusiva del target del evento puede confundir los clics en el padding del diálogo con clics en el backdrop. Nunca crees un objetivo de cierre invisible de pantalla completa que capture clics destinados al diálogo.
La restauración nativa del foco normalmente regresa al elemento invocador. El flujo de la aplicación puede anular eso solo con una razón justificada. Cuando un diálogo crea una nueva fila y elimina el botón "Agregar fila", la primera celda de la nueva fila es un destino lógico. Captura esta política en el invocador, porque el diálogo reutilizable no puede inferir qué cambió en el flujo de trabajo circundante.
Para diálogos apilados, es preferible cambiar el contenido dentro de un mismo diálogo. Si un segundo modal es inevitable, mantén una pila: solo su entrada superior puede cerrarse mediante Escape o backdrop, los diálogos en segundo plano permanecen inertes y cerrar la entrada superior restaura el foco al control que lo abrió dentro del diálogo anterior. Un booleano global no puede representar esa relación.
Prueba el comportamiento, no solo los atributos renderizados:
1. Open with Enter and Space; verify focus enters the intended target.
2. Tab from the last control and Shift+Tab from the first; verify background is unreachable.
3. Press Escape; verify one top dialog closes and controlled state becomes false.
4. Use the visible close and Cancel controls with keyboard, pointer, and touch.
5. Close normally; verify focus returns to the opener.
6. Remove the opener during completion; verify focus moves to the chosen logical successor.
7. Read with a screen reader; verify one useful title and no flattened rich description.
8. Open a destructive confirmation; verify initial focus is on the least destructive action.
9. Rerender repeatedly while open; verify no duplicate-open exception or focus reset.
10. Unmount during navigation; verify no listener leak or focus jump to the document body.
11. At 200% zoom and a small viewport, verify title, controls, and scrollable content remain reachable.
12. Run automated accessibility checks, then repeat the manual focus workflow they cannot prove.Respuesta de muestra de alta calidad
“Trataría el modal como una máquina de estados de foco e interacción, no como un panel con un gran valor de apilamiento. Cuando se abre, el resto del documento debe volverse inerte, el diálogo necesita un nombre vinculado a su título visible y el foco debe moverse a un objetivo seleccionado según la tarea. Mientras esté abierto, la navegación por teclado permanece adentro y un control visible de cierre o cancelación siempre es alcanzable. Al cerrar, el foco regresa al disparador que lo abrió a menos que el flujo de trabajo lo haya reemplazado, en cuyo caso quien lo invoca proporciona un sucesor lógico.
Para los navegadores actuales, usaría el elemento nativo dialog y llamaría a showModal(). Eso me da la capa superior, el backdrop, la inerticidad de fondo y el comportamiento central del foco modal. Establecer open o llamar a show() produciría un comportamiento no modal, por lo que no usaría ninguno de los dos como sustituto. Si el ejercicio prohíbe el elemento nativo, reproduciría los mismos invariantes con un rol dialog, nombre accesible, inerticidad exterior real, contención del foco, manejo de Escape, un portal y restauración, preferiblemente a través de una primitiva probada existente en lugar de código ad hoc nuevo para atrapar el foco.
El foco inicial depende del contenido. Enfocaría el campo relevante para un formulario corto, un encabezado estático para contenido estructurado extenso y Cancelar para una acción irreversible. Haría referencia a una descripción corta solo cuando pueda entenderse como un solo anuncio; las listas y los párrafos múltiples se mantienen navegables como estructura.
En React sincronizaría el estado controlado con showModal() y close() dentro de efectos, protegería contra llamadas duplicadas, escucharía directamente el evento cancel que no burbujea y eliminaría los listeners en la limpieza. El cierre por backdrop sería una política de producto explícita. Verificaría la apertura por teclado, ambas direcciones de tabulación, Escape, cierre explícito, nombres en lectores de pantalla, restauración del foco cuando el disparador existe o desaparece, rerenderizados repetidos, diálogos apilados, zoom y viewports pequeños. Las comprobaciones automatizadas complementan ese flujo de trabajo pero no lo reemplazan”.
Errores comunes
- Estilar una superposición centrada y llamarla modal → los controles de fondo siguen siendo accesibles para el teclado o la tecnología de asistencia → Define y prueba la inerticidad y el ciclo de vida del foco.
- Agregar semántica modal sin bloquear la interacción externa → el árbol de accesibilidad promete un estado que los usuarios videntes con puntero no experimentan → Establece semántica modal solo cuando el comportamiento sea genuinamente modal.
- Alternar el atributo nativo
open→ el diálogo se muestra sin el comportamiento deshowModal()→ Usa el método modal correcto y sincronízalo con el estado de la aplicación. - Enfocar siempre el primer control → el contenido extenso puede comenzar fuera de pantalla y el trabajo destructivo puede enfocar la acción peligrosa → Elige el foco inicial a partir de la estructura del contenido y la consecuencia.
- Poner varios párrafos en una sola descripción accesible → los lectores de pantalla anuncian un bloque desestructurado → Haz referencia solo a una descripción corta y deja el contenido enriquecido como navegable.
- Depender únicamente de Escape → los usuarios táctiles y de pulsadores pueden no tener una ruta clara de cierre → Incluye un control visible y nombrado para cerrar o cancelar.
- Cerrar en cada clic del backdrop → la entrada accidental del puntero descarta el trabajo → Haz que el cierre ligero (light-dismiss) sea una política de producto deliberada y comprobable.
- Escribir una trampa de foco manual antes de verificar la plataforma o la biblioteca → los casos extremos se multiplican con controles deshabilitados, cambios en el DOM, portales y diálogos anidados → Prefiere el elemento nativo o una primitiva madura y probada.
- Llamar a
showModal()durante el renderizado o en cada ejecución del efecto → el renderizado en el servidor falla o el navegador lanza una excepción y el foco se reinicia → Llama a métodos imperativos en efectos protegidos. - Restaurar el foco a un disparador eliminado → el foco cae en el body del documento y se pierde el contexto del teclado → Haz que el invocador identifique un sucesor lógico cuando el flujo de trabajo modifique la página.
- Tratar un escaneo automatizado como prueba completa → no puede juzgar el orden del foco ni la intención del flujo de trabajo → Realiza la secuencia manual completa con teclado y lector de pantalla.
Preguntas de seguimiento y respuestas
Pregunta de seguimiento 1: ¿Qué cambia si el elemento nativo dialog está prohibido?
Renderiza un portal que contenga un contenedor con rol dialog, una referencia al título visible y semántica modal. Haz que cada raíz de aplicación en segundo plano sea inerte, no simplemente atenuada visualmente. Captura el disparador de apertura, elige y establece el foco inicial, contiene ambas direcciones de tabulación a medida que cambian los descendientes enfocables, maneja Escape en el diálogo activo, restaura el foco y limpia cada mutación y listener. Explica por qué una primitiva mantenida es más segura que reimplementar este comportamiento entre navegadores para cada diálogo del producto.
Pregunta de seguimiento 2: ¿Cómo manejas un formulario con errores de validación?
Mantén el diálogo abierto. Mueve el foco al primer campo no válido o a un resumen de errores que enlace a los campos no válidos, expón cada mensaje a través de la descripción accesible de su campo y preserva los valores ingresados. No anuncies cada error de campo como una única descripción de diálogo. Después de un envío exitoso, cierra solo cuando el resultado de la aplicación esté confirmado y luego mueve el foco al elemento que representa el resultado.
Pregunta de seguimiento 3: ¿Hacer clic en el backdrop debería cerrar el diálogo?
Derívalo del riesgo de pérdida. Un selector ligero puede permitirlo; un formulario extenso o una confirmación destructiva generalmente deberían requerir una decisión explícita. Si está habilitado, descarta solo cuando la secuencia del puntero comience y termine en la región del backdrop, usa la misma transición de estado que Cancelar y prueba un pointer down adentro seguido de un pointer up afuera para que un arrastre no se confunda con una intención.
Pregunta de seguimiento 4: ¿Cómo animas el cierre sin romper la restauración del foco?
Separa el "cierre solicitado" de "eliminado del DOM". Marca el diálogo como en proceso de cierre, detén nuevas acciones, reproduce la transición de salida, luego llama a la ruta de cierre nativa y actualiza el estado antes de desmontar. Respeta las preferencias de reducción de movimiento y proporciona una alternativa de finalización delimitada. La restauración del foco ocurre en el límite de cierre real, no cuando la opacidad cambia por primera vez.
Pregunta de seguimiento 5: ¿Qué sucede si se abre un segundo diálogo desde el primero?
Evítalo cuando un flujo de múltiples pasos dentro de un solo diálogo sea más claro. Si es obligatorio, almacena una pila ordenada con un disparador de apertura para cada entrada. Solo la entrada superior responde a Escape o al backdrop; los diálogos inferiores permanecen bloqueados. Cerrar el hijo restaura el foco a su disparador dentro del padre, y cerrar el padre luego restaura el foco al disparador de la página.
Pregunta de seguimiento 6: ¿Cómo evitas el desplazamiento del body y el desplazamiento del layout (layout shift)?
Trata el bloqueo de scroll como una política visual y de entrada separada de la inerticidad semántica. Registra la posición de desplazamiento actual, aplica un bloqueo centralizado mientras el primer modal esté abierto, compensa el ancho de la barra de desplazamiento que desaparece cuando sea necesario y libera solo después de que se cierre el último modal. Prueba teclados virtuales móviles, regiones de scroll anidadas, zoom y cambios de ruta; la propiedad basada en recuento de referencias evita que un diálogo desbloquee la página debajo de otro.
Pregunta de seguimiento 7: ¿Cómo probarías esto en CI?
Usa pruebas de componentes para transiciones controladas de apertura y cierre, etiquetas, ramas de foco inicial, manejo de cancelaciones y limpieza. Agrega pruebas de navegador que activen el disparador real, avancen y retrocedan a través del foco, presionen Escape, eliminen el disparador de apertura y ejerciten diálogos apilados. Ejecuta reglas de accesibilidad automatizadas para regresiones estructurales y luego conserva una matriz manual a través de configuraciones representativas de lector de pantalla, teclado, zoom, pantalla táctil y alto contraste, porque las aserciones de CI no pueden juzgar cada anuncio o elección de flujo de trabajo.