Arreglar el error de manejo de foco de Greenhouse me enseñó la diferencia entre un diálogo y una página
Una revisión de UI/UX de Greenhouse marcó un error de accesibilidad real: la app tiene cuatro “vistas de ventana completa” (la página de detalle de un proyecto, el navegador de la bóveda, el navegador de cosecha, un tablero kanban) que reemplazan las zonas principales del panel. Al abrir una con el teclado o un lector de pantalla, el foco simplemente cae al vacío, termina en <body>, porque todo el subárbol de zonas se desmontó y nada reclamó el foco en su lugar. La solución sugerida en el ticket sonaba razonable: poner tabindex="-1" en el encabezado de cada vista, enfocarlo cuando se monta, y cuando se sale de la vista, restaurar el foco al botón en el que se haya hecho clic para llegar ahí.
Esa tercera parte resultó imposible de construir tal como estaba escrita, y descubrir por qué fue la parte realmente interesante de este ticket.
El panel de Greenhouse alterna estas vistas usando una sola cadena grande {#if selectedItem}...{:else if vaultOpen}...{:else}<zones/>{/if}. Svelte no hace un diffing inteligente entre ramas así: cuando la condición cambia, todo el DOM de la rama vieja se destruye y el nuevo se construye desde cero. Lo que significa que el botón que abrió, digamos, la vista de la bóveda no solo pierde el foco cuando la vista se cierra, ya no existe. Se destruyó en el instante en que la vista se abrió. Guardar una referencia a él y llamar a .focus() después simplemente… no hace nada. Sin error, sin advertencia, es una llamada de foco sobre un fantasma.
Una vez que entendí eso, la pregunta real pasó a ser: cuál es el patrón correcto acá, no solo “qué solución alternativa evita el problema del nodo fantasma”. Y resulta que SÍ hay una respuesta correcta, solo hacía falta dejar de suponer que esto era un problema de diálogo. Restaurar el foco al elemento que lo disparó es lo correcto para un modal de verdad, algo que el patrón de diálogo de WAI-ARIA deja muy claro, porque el contenido detrás del modal nunca se fue a ningún lado. Sigue ahí, esperando. Pero estas vistas no son superposiciones, son reemplazos completos, más parecido a una navegación de página en una aplicación de una sola página que a la apertura de un diálogo. Y la convención establecida para ese caso es completamente distinta: enfocar el encabezado de la página nueva, no el elemento que disparó la acción. React Router hace esto. La guía de diseño de GOV.UK para cargas de página por AJAX hace esto. Eso no es una solución alternativa, es simplemente el patrón correcto para contenido que se reemplaza en vez de apilarse.
Así que la solución terminó siendo más simple de lo que pedía el ticket, no más compleja. Cada vista enfoca su propio encabezado una sola vez, protegido para que una recarga interna (renombrar un proyecto, avanzar su etapa) no jale el foco de vuelta e interrumpa lo que se esté haciendo. Y las zonas del panel reciben el mismo tratamiento al volver, ya que esa rama es un remontaje tan nuevo como cualquiera de las vistas que la reemplazan.
Lo verifiqué manejando la app compilada de verdad a través de una sesión real de WebDriver, haciendo clic en cada apertura y cierre y leyendo document.activeElement después, incluyendo el caso anidado más complicado: abrir el tablero, abrir un proyecto desde una tarjeta en él, cerrar y volver. El foco cae correctamente sobre el propio encabezado del tablero remontado, no en la nada. Una prueba unitaria puede verificar que tabindex="-1" exista en un elemento. No puede decir si el foco realmente llegó ahí después de una transición real. Para un error que es enteramente sobre a dónde va el foco, esa distinción es todo lo que importa.
Lecturas relacionadas
Un anillo de foco que no debía estar a máxima intensidad
Una línea verde brillante debajo de la barra superior en cada inicio, un intento de captura de pantalla que terminó mostrando las ventanas equivocadas, y una muestra de color que probó las matemáticas del color pero no la respuesta.
Rediseño de navegación: pestañas en la barra superior y un diálogo de Settings
Ocho controles de footer con el mismo peso visual, una clave de i18n cumpliendo doble función, y el patrón ARIA que el propio test de a11y del proyecto no dejó construir a medias.
El contorno estaba bien, la caja alrededor de la cual se dibujaba no
Un anillo de foco que abarcaba toda una fila de encabezado, una ventana de WebDriver que macOS nunca convirtió en ventana activa, y el truco del span en línea que reduce un contorno hasta las palabras que contiene.