Módulo 7: Primitives And Component Libraries

Un Dialog bien hecho

Descripción

Todo el módulo converge aquí. El motor de decisión de la lección 6 marcó al Dialog como el candidato más claro para apoyarse en una primitiva —combina patrón de teclado no trivial y manejo de foco, la peor combinación para reinventar—. Esta lección no se queda en la teoría: construye el checklist completo de a11yAudit() —las siete casillas que viste parciales en la lección 1 (Button, tres casillas) y en la lección 2 (Checkbox, cuatro casillas)— y lo aplica al widget que las necesita casi todas. Vas a comparar, número contra número, un Dialog casero contra uno construido sobre Dialog.* de Radix, y vas a ver exactamente dónde se rompe el casero.

Conexión con el módulo. Esta es la lección de mayor profundidad técnica del módulo, y la que cierra el argumento antes del proyecto (L8). Reusa a11yAudit() sin ningún cambio de forma —mismas siete casillas, misma función— y lo que cambia es cuáles casillas aplican a un modal: entran escape y focusTrap, que ni el Button ni el Checkbox necesitaban. El proyecto de la lección 8 va a repetir exactamente este ejercicio sobre el carrito real de Mercado —esta lección es su ensayo general, con nombres genéricos en vez del caso de negocio—.

Una analogía: la caja fuerte con la puerta que no cierra bien

Una caja fuerte tiene un trabajo: cuando está cerrada, nada entra ni sale sin la combinación correcta. Imagina una caja fuerte con un defecto de fabricación en el mecanismo del pestillo: por fuera, cerrada, se ve idéntica a una que funciona bien —mismo acero, mismo dial, misma puerta pesada—. Pero empújala un poco desde cierto ángulo y la puerta cede. El defecto no está en lo que se ve; está en el mecanismo que se supone que resiste cuando alguien lo prueba desde el ángulo que el fabricante no consideró.

Un Dialog casero, sin focus-trap, es esa caja fuerte con el defecto: visualmente está "cerrado" —el modal está abierto, la página de atrás se ve bloqueada—, pero empújalo desde el ángulo correcto —presiona Tab repetidamente— y el foco se escapa hacia el catálogo detrás, como si el modal nunca hubiera estado ahí para el teclado. Nadie lo nota probando con mouse, exactamente como nadie nota el defecto de la caja fuerte mirándola cerrada. Se necesita empujarla desde el ángulo correcto.

Guarda la imagen: un Dialog sin focus-trap se ve cerrado y no lo está, para quien lo prueba desde el ángulo que importa —el teclado—.

Ejemplo trabajado: a11yAudit() completo sobre un Dialog

Definimos dos versiones. El Dialog casero tiene un detalle importante, y es realista, no exagerado a propósito: el trigger sí es un <button> real —el desarrollador que lo construyó sabía al menos eso—, así que abre con clic y con teclado sin problema. Lo que falla es todo lo que pasa después de que se abre: el panel es un <div className="modal"> sin role, sin manejo de Escape, sin focus-trap, y sin aria-modal ni nombre accesible.

// L7 - un Dialog bien hecho. a11yAudit() completo (las mismas 7 casillas de L1-L2),
// aplicado al widget mas exigente del modulo: un modal.
// nota: "arrows" no aplica a un Dialog basico (no hay una lista que navegar adentro),
// por eso no entra en `applicable` -- distinto widget, distinto subconjunto de casillas.

const CHECKS = [
  { key: 'role',      label: 'rol ARIA correcto (role="dialog")' },
  { key: 'focusable',  label: 'el trigger es focuseable con Tab' },
  { key: 'activate',  label: 'Enter/Space en el trigger abre' },
  { key: 'escape',    label: 'Escape cierra' },
  { key: 'arrows',    label: 'flechas navegan entre opciones' },
  { key: 'focusTrap', label: 'foco atrapado dentro mientras esta abierto' },
  { key: 'ariaState', label: 'aria-modal="true" + nombre accesible (aria-labelledby)' },
];

function a11yAudit(component) {
  const applicable = CHECKS.filter((c) => component.applicable.includes(c.key));
  const missing = applicable.filter((c) => !component.pass.includes(c.key)).map((c) => c.label);
  return { name: component.name, score: applicable.length - missing.length, total: applicable.length, missing };
}

// el Dialog casero: el trigger SI es un <button onClick> real (asi que abre con teclado),
// pero el panel es un <div className="modal"> sin rol, sin Escape, sin trap, sin aria-modal.
const homemadeDialog = {
  name: 'Dialog casero (trigger real, panel de <div>)',
  applicable: ['role', 'focusable', 'activate', 'escape', 'focusTrap', 'ariaState'],
  pass: ['focusable', 'activate'],
};

// el Dialog sobre Dialog.Root/Trigger/Content de Radix: todo lo anterior resuelto.
const primitiveDialog = {
  name: 'Dialog sobre una primitiva (Radix Dialog)',
  applicable: ['role', 'focusable', 'activate', 'escape', 'focusTrap', 'ariaState'],
  pass: ['role', 'focusable', 'activate', 'escape', 'focusTrap', 'ariaState'],
};

console.log('=== a11yAudit: Dialog casero vs Dialog sobre una primitiva ===\n');
for (const c of [homemadeDialog, primitiveDialog]) {
  const r = a11yAudit(c);
  console.log(`${r.name}: ${r.score}/${r.total}`);
  if (r.missing.length) console.log('  falta: ' + r.missing.join(', '));
}

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== a11yAudit: Dialog casero vs Dialog sobre una primitiva ===

Dialog casero (trigger real, panel de <div>): 2/6
  falta: rol ARIA correcto (role="dialog"), Escape cierra, foco atrapado dentro mientras esta abierto, aria-modal="true" + nombre accesible (aria-labelledby)
Dialog sobre una primitiva (Radix Dialog): 6/6

El número cuenta la historia completa: 2/6 contra 6/6, en el mismo checklist, para el mismo widget. Fíjate en cuáles dos casillas pasa el casero: focusable y activate —el trigger, por ser un <button> real, hereda gratis lo que cualquier <button> nativo trae—. Eso es justo lo peligroso del caso: el Dialog casero no se siente completamente roto al probarlo. Abre con clic, abre con Enter, se ve perfecto. El problema empieza exactamente donde deja de ser visible sin usar teclado a fondo: presiona Tab repetidamente dentro del modal abierto y el foco eventualmente llega al último elemento enfocable del modal y, sin nada que lo detenga, sigue avanzando hacia el catálogo detrás —el mismo defecto de la caja fuerte de la analogía—. Presiona Escape y no pasa nada, porque nadie escribió ese manejador. Y si un lector de pantalla anuncia algo al abrir, anuncia "grupo" o nada, porque no hay role="dialog" ni aria-modal ni conexión a un nombre.

El Dialog sobre la primitiva pasa las seis sin que hayas escrito una sola línea de manejo de foco o de teclado — eso es, literalmente, la razón de ser de este módulo entero, aterrizada en el caso donde más se nota. Y fíjate en el comentario del código: arrows no está en applicable para ninguno de los dos. Un Dialog básico no tiene una lista de opciones que navegar con flechas adentro —eso es un patrón distinto (Menu, Listbox), que vas a auditar en el proyecto con el SortMenu—. El checklist se adapta al widget: siete casillas existen, pero cada tipo de componente se mide contra el subconjunto que realmente le aplica.

Profundización: qué hace, exactamente, el focus-trap

De las cuatro casillas que el Dialog casero pierde, focusTrap es la más difícil de replicar a mano bien, así que vale la pena desarmarla. Un focus-trap tiene que resolver, como mínimo, tres comportamientos simultáneos:

  1. Al abrir, el foco se mueve adentro del diálogo —normalmente al primer elemento enfocable, o a un elemento específico que tú elijas—. Sin esto, el foco se queda donde estaba (por ejemplo, en el botón "Ver carrito"), y un usuario de teclado no tiene ninguna señal de que algo nuevo apareció en pantalla.
  2. Mientras está abierto, Tab y Shift+Tab recorren solo los elementos dentro del diálogo, en un ciclo: al llegar al último con Tab, vuelve al primero; al llegar al primero con Shift+Tab, vuelve al último. El resto de la página —el catálogo detrás del overlay— queda completamente fuera del recorrido, aunque técnicamente siga en el DOM.
  3. Al cerrar, el foco vuelve exactamente al elemento que lo abrió —el Trigger—, para que quien navegaba con teclado no "pierda el lugar" en la página.

Cada uno de esos tres puntos tiene casos borde reales: ¿qué pasa si el diálogo no tiene ningún elemento enfocable adentro (solo texto)? ¿Qué pasa si el usuario cierra el diálogo con un método distinto al Close —por ejemplo, clic en el overlay—, el foco igual vuelve al trigger? ¿Qué pasa si dentro del diálogo hay otro elemento que dispara su propio popover? Radix ya resolvió estos casos, probados contra el patrón Dialog (Modal) de la APG. Reconstruir el punto 2 en particular —el ciclo que mantiene el foco adentro— requiere escuchar el evento keydown, calcular cuáles son el primer y el último elemento enfocable dinámicamente (pueden cambiar si el contenido del modal cambia), y prevenir el comportamiento por default del navegador en el momento exacto. Es exactamente el tipo de código que, escrito a mano, funciona en la demo y falla en el caso borde que nadie probó — el argumento completo de la lección 6, aterrizado en líneas de código concretas.

El ciclo del focus-trap (Tab dentro de un Dialog abierto):

  [Trigger] --abre-->  (foco entra al Dialog)
                              │
                    ┌── Close ←→ Item 1 ←→ Item 2 ──┐
                    │    ↑ Shift+Tab      Tab ↓      │
                    └────────────────────────────────┘
                    (el foco NUNCA sale hacia el catalogo de atras)
                              │
                          --Escape/Close-->  foco vuelve a [Trigger]

Errores comunes

Confiar en overflow: hidden en el <body> como si fuera un focus-trap. Qué pasa: se agrega document.body.style.overflow = 'hidden' al abrir el modal, pensando que eso "bloquea" la página de atrás. Por qué pasa: sí bloquea el scroll del mouse, y visualmente parece que la página quedó "congelada". Cómo detectarlo: el modal se ve bloqueado con mouse pero Tab sigue moviendo el foco hacia elementos del catálogo detrás. Cómo corregirlo: overflow: hidden es una propiedad puramente visual/de scroll — no tiene ningún efecto sobre el orden de tabulación del teclado. El focus-trap real requiere interceptar el evento de teclado, como describe la profundización de esta lección; son dos problemas distintos (scroll vs. foco) que se resuelven con mecanismos distintos, y confundirlos dejaría el teclado completamente desprotegido mientras la página "se ve" bloqueada.

Cerrar el modal con Escape pero olvidar devolver el foco al trigger. Qué pasa: se agrega un keydown que escucha Escape y cierra el modal (setOpen(false)), pero el foco, al perderse el elemento donde estaba (que ya no existe en el DOM), cae por default al <body> — invisible para el usuario de teclado, que ahora no sabe dónde está. Por qué pasa: cerrar el modal se siente completo — la caja desapareció, ¿qué más falta? Cómo detectarlo: después de cerrar con Escape, presiona Tab — si el foco reaparece en un lugar impredecible de la página (o en el primer elemento focuseable del documento) en vez de en el botón "Ver carrito", falta el retorno de foco. Cómo corregirlo: guarda una referencia al elemento que tenía el foco antes de abrir el modal (normalmente el Trigger), y al cerrar, devuélvele el foco explícitamente (triggerRef.current.focus()). Es el tercer punto del focus-trap de la profundización, y el que más se olvida al construir a mano porque no rompe nada visible — solo desorienta a quien navega sin mouse.

Medir accesibilidad con la vista y dar el Dialog casero por bueno. Qué pasa: alguien abre el Dialog casero, ve que se centra bien, tiene los colores correctos, cierra con el botón "×", y lo aprueba. Por qué pasa: es exactamente la revisión que la lección 2 ya nombró — mirar la pantalla, no navegar sin mouse. Cómo detectarlo: tu proceso de revisión de este componente específico no incluyó presionar Tab repetidamente dentro del modal abierto, ni Escape, ni verificar a dónde vuelve el foco al cerrar. Cómo corregirlo: usa exactamente las seis casillas de a11yAudit() como tu lista de verificación manual — no necesitas el script de Node en producción, necesitas las preguntas que el script te obligó a nombrar: ¿tiene el rol correcto?, ¿el trigger es focuseable?, ¿activa con teclado?, ¿Escape cierra?, ¿el foco queda atrapado?, ¿anuncia su nombre? Seis preguntas, dos minutos, y el Dialog casero de esta lección no habría pasado la revisión.

Ejercicios

Ejercicio 1 — Diagnostica el fallo. Un compañero prueba un Dialog y reporta: "abre bien con clic y con Enter, pero si presiono Tab varias veces el foco termina en el buscador del header, que está detrás del modal". ¿Cuál de las seis casillas de a11yAudit() está fallando, específicamente?

Ver solución

focusTrap. El síntoma descrito —el foco "se escapa" hacia un elemento detrás del modal al presionar Tab repetidamente— es exactamente la definición de un focus-trap ausente o mal implementado: el ciclo de tabulación no está limitado a los elementos dentro del diálogo. Que abra bien con clic y Enter confirma que focusable y activate sí pasan (el trigger está bien construido); el problema es específicamente lo que pasa después de abrir, dentro del contenido, que es el trabajo de Dialog.Content en una primitiva.

Ejercicio 2 — Predice el score. Sin correr nada: si al homemadeDialog del ejemplo trabajado le agregaras manejo de Escape (un keydown que cierra el modal) pero sin tocar nada más —sigue sin role, sin focus-trap, sin aria-modal—, ¿qué score esperarías, y qué casillas seguirían faltando?

Ver solución

3/6. Ahora pasaría focusable, activate y escape (la nueva). Seguirían faltando role (el panel sigue sin role="dialog"), focusTrap (agregar el manejo de Escape no implementa el ciclo de tabulación — son mecanismos independientes, como mostró el primer error común de esta lección) y ariaState (sin aria-modal ni nombre accesible conectado). El ejercicio refuerza algo importante: cada casilla se arregla con un mecanismo distinto — agregar Escape no "de rebote" arregla el focus-trap ni el rol; son piezas de código separadas que hay que resolver una por una, exactamente el trabajo que una primitiva ya hizo por ti de una sola vez.

Ejercicio 3 — Explica el focus-trap sin código. En dos o tres frases, sin usar la palabra "código" ni mencionar ningún evento de JavaScript, explica a alguien no técnico qué es un focus-trap y por qué un modal lo necesita.

Ver solución

Un focus-trap es lo que hace que, mientras una ventana emergente está abierta, el "cursor de teclado" (el resaltado que ves moverse cuando presionas Tab) se quede encerrado dentro de esa ventana, sin poder escaparse hacia el resto de la página que quedó detrás, tapada. Un modal lo necesita porque, mientras está abierto, todo lo que hay detrás no debería ser alcanzable — igual que no podrías interactuar con lo que hay debajo de una hoja de papel que tapa completamente tu escritorio—; sin el focus-trap, alguien que navega solo con teclado podría terminar "tocando" botones de la página de atrás sin darse cuenta de que el modal sigue técnicamente abierto encima.

Resumen y siguiente paso

En esta lección aplicaste a11yAudit() completo al widget que el módulo entero usó como ejemplo más exigente: un Dialog casero, con trigger real pero panel sin rol/Escape/focus-trap/aria-modal, sacó 2/6; el mismo Dialog sobre Dialog.* de Radix sacó 6/6, sin que escribieras una línea de manejo de foco o teclado. Con la caja fuerte que se ve cerrada pero no resiste el ángulo correcto viste por qué el defecto es tan fácil de no notar. Y desarmaste, paso por paso, qué hace exactamente un focus-trap —entrada, ciclo, retorno— para que la próxima vez que veas Dialog.Content en tu editor, sepas con precisión qué problema difícil resolvió por ti.

Antes de avanzar deberías poder: nombrar los tres comportamientos de un focus-trap completo (entrada, ciclo, retorno); explicar por qué overflow: hidden no es un focus-trap; y recitar las seis preguntas de a11yAudit() para un Dialog de memoria, sin necesitar el script.

Ya tienes las siete lecciones del módulo: por qué existen las primitivas y por qué su ausencia es invisible (L1-L2), su anatomía real (L3), cómo se visten (L4), de dónde sale el código (L5), cuándo usarlas (L6), y el caso más exigente auditado a fondo (L7). El proyecto (L8) te pone a construir: el carrito de Mercado como un Dialog real, y el orden del catálogo como un DropdownMenu real, los dos sobre primitivas, vestidos con tus tokens, y auditados con exactamente el mismo a11yAudit() que acabas de usar aquí.

Recursos