Módulo 8: Project Build Mercados Design System
Agrega primitivas accesibles
Descripción
Todo lo que construiste hasta aquí —tokens, utilidades, contraste, variantes, responsive, dark— resuelve la apariencia del sistema de Mercado. Pero hay un componente que el storefront necesita y que ninguna de las seis capas anteriores toca: un Dialog de "vista rápida" del producto, que se abre al hacer clic en una tarjeta del catálogo y muestra el detalle sin salir de la página. Un Dialog no es solo apariencia —es comportamiento: qué pasa cuando el usuario presiona Tab, qué pasa si presiona Escape, a dónde vuelve el foco cuando se cierra, qué anuncia un lector de pantalla al abrirlo—. Ese comportamiento es exactamente lo que el módulo 7 llamó "la accesibilidad que no ves" — invisible cuando funciona, y devastadora cuando no.
Esta lección instala el modelo del módulo 7 —construir un Dialog desde cero versus montarlo sobre una primitiva accesible (el patrón shadcn/Radix)— y agrega la pieza que faltaba: a11yAudit, un modelo Node que audita un componente contra un checklist fijo de siete requisitos de accesibilidad para un diálogo modal. Vas a auditar dos versiones del mismo QuickViewDialog: una construida a mano, con lo mínimo para que "se vea" como un modal, y otra montada sobre una primitiva. La diferencia entre los dos números es el argumento más contundente de esta guía a favor de no reinventar lo que una primitiva ya resolvió.
Conexión con el módulo. Esta lección integra la fila M7 del diseño de la guía —primitivas sin estilo pero accesibles, que tú vistes con tus tokens; copiar-el-código (shadcn) frente a dependencia; cuándo construir y cuándo usar una librería—. El Dialog que aquí auditas usa exactamente los tokens de la lección 2 (color.surface, color.text) y las utilidades de la lección 3 para su apariencia — la primitiva resuelve el comportamiento, tu sistema resuelve el estilo. Para la mecánica completa de primitivas y librerías, repasa el módulo 7 (module-07-primitives-and-component-libraries) de esta guía.
Una analogía: la cerradura de seguridad certificada frente a la que arma un aficionado
Imagina dos cerraduras para la puerta principal de una tienda. La primera la fabrica un aficionado con buena intención: junta un mecanismo, una llave, y prueba que la puerta abre y cierra —funciona, a simple vista—. La segunda es una cerradura certificada por un laboratorio de seguridad, que la sometió a un protocolo fijo de pruebas: resistencia a la ganzúa, resistencia al taladro, comportamiento bajo impacto, qué pasa si se fuerza desde dentro versus desde fuera. La cerradura del aficionado también abre y cierra la puerta — a simple vista, las dos funcionan igual—. La diferencia solo aparece cuando alguien corre el protocolo completo de pruebas: la certificada pasa las quince, la casera pasa dos o tres, las que el aficionado pensó a probar sin querer. a11yAudit es ese protocolo de laboratorio, aplicado a un Dialog: no es que el Dialog casero "se vea mal" — se ve idéntico al de la primitiva. La diferencia está en lo que un vistazo no puede medir: si el foco queda atrapado, si Escape cierra, si el foco vuelve a donde estaba. Eso solo lo revela el protocolo, corrido de verdad.
Ejemplo trabajado: el mismo checklist, dos implementaciones
Primero, el checklist —siete requisitos fijos, los mismos para cualquier Dialog que se audite—:
// a11yAudit.js — el protocolo de siete requisitos para un dialog modal accesible.
const DIALOG_CHECKLIST = [
{ id: 'role', label: 'role="dialog" + aria-modal="true"' },
{ id: 'labelledby', label: 'aria-labelledby apunta al titulo visible' },
{ id: 'initialFocus', label: 'el foco entra al primer elemento enfocable al abrir' },
{ id: 'focusTrap', label: 'Tab/Shift+Tab quedan atrapados dentro del dialog' },
{ id: 'escapeCloses', label: 'Escape cierra el dialog' },
{ id: 'returnFocus', label: 'el foco vuelve al trigger que lo abrio, al cerrar' },
{ id: 'clickOutside', label: 'un click fuera del dialog lo cierra' },
];
function a11yAudit(name, implemented) {
const results = DIALOG_CHECKLIST.map((check) => ({ ...check, pass: Boolean(implemented[check.id]) }));
const passed = results.filter((r) => r.pass).length;
return { name, results, passed, total: DIALOG_CHECKLIST.length };
}
function printAudit(audit) {
console.log(`-- ${audit.name}: ${audit.passed}/${audit.total} --`);
for (const r of audit.results) console.log(' [' + (r.pass ? 'x' : ' ') + '] ' + r.label);
}
// lo que CADA implementacion realmente resuelve (verificado leyendo su codigo, no adivinado).
const caseroImpl = {
role: true, // si escribieron role="dialog" a mano
labelledby: false, // el titulo no esta conectado con aria-labelledby
initialFocus: false, // el foco se queda donde estaba al hacer click
focusTrap: false, // Tab escapa hacia el resto de la pagina
escapeCloses: false, // no hay listener de teclado
returnFocus: false, // al cerrar, el foco no vuelve al boton que abrio
clickOutside: true, // si tienen un onClick en el overlay de fondo
};
const radixImpl = {
role: true, labelledby: true, initialFocus: true, focusTrap: true,
escapeCloses: true, returnFocus: true, clickOutside: true,
};
console.log('=== Dialog casero vs Dialog sobre primitiva (Radix), mismo checklist ===\n');
printAudit(a11yAudit('QuickViewDialog casero', caseroImpl));
console.log('');
printAudit(a11yAudit('QuickViewDialog (Radix)', radixImpl));
Y aquí están las dos implementaciones reales que ese checklist audita — esto se muestra, es el código que el checklist está midiendo—.
Primero, el Dialog casero — lo mínimo para que "se vea" como un modal:
// QuickViewDialogCasero.jsx — construido a mano, sin manejar teclado ni foco.
function QuickViewDialogCasero({ product, open, onClose }) {
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50" onClick={onClose}>
<div role="dialog" className="bg-surface text-foreground rounded-md p-4" onClick={(e) => e.stopPropagation()}>
<h2>{product.name}</h2>
<p className="text-primary">{product.price}</p>
<button onClick={onClose}>Cerrar</button>
</div>
</div>
);
}
// tiene role="dialog" y cierra al hacer click afuera (onClick en el overlay).
// NO mueve el foco al abrir, NO atrapa Tab, NO escucha Escape, NO devuelve el foco al cerrar,
// y no tiene aria-modal ni aria-labelledby: un lector de pantalla no sabe que esto es un dialogo modal.
Y el mismo Dialog, montado sobre una primitiva accesible —el patrón del módulo 7—, vestido con los tokens y utilidades de este capstone:
// QuickViewDialog.jsx — la MISMA apariencia, sobre una primitiva accesible.
import * as Dialog from '@radix-ui/react-dialog';
function QuickViewDialog({ product, open, onOpenChange }) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="bg-surface text-foreground rounded-md p-4">
<Dialog.Title>{product.name}</Dialog.Title>
<p className="text-primary">{product.price}</p>
<Dialog.Close asChild>
<button>Cerrar</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// las clases (bg-surface, text-foreground, text-primary, rounded-md, p-4) son IDENTICAS
// a las del componente casero — la apariencia no cambio. Lo que cambio es que role="dialog",
// aria-modal, aria-labelledby (via Dialog.Title), el foco trap, Escape y el retorno del foco
// los resuelve la primitiva, no un desarrollador reinventandolos.
Qué esperar. Al correr node a11yAudit.js, la salida es exactamente esta:
=== Dialog casero vs Dialog sobre primitiva (Radix), mismo checklist ===
-- QuickViewDialog casero: 2/7 --
[x] role="dialog" + aria-modal="true"
[ ] aria-labelledby apunta al titulo visible
[ ] el foco entra al primer elemento enfocable al abrir
[ ] Tab/Shift+Tab quedan atrapados dentro del dialog
[ ] Escape cierra el dialog
[ ] el foco vuelve al trigger que lo abrio, al cerrar
[x] un click fuera del dialog lo cierra
-- QuickViewDialog (Radix): 7/7 --
[x] role="dialog" + aria-modal="true"
[x] aria-labelledby apunta al titulo visible
[x] el foco entra al primer elemento enfocable al abrir
[x] Tab/Shift+Tab quedan atrapados dentro del dialog
[x] Escape cierra el dialog
[x] el foco vuelve al trigger que lo abrio, al cerrar
[x] un click fuera del dialog lo cierra
Lee el checklist casero con atención a los dos que sí pasa, no solo a los cinco que fallan: role="dialog" está —quien lo escribió sabía que un modal necesita ese atributo— y "click fuera cierra" también, porque agregar un onClick al overlay de fondo es intuitivo y fácil de acordarse. Son, justamente, los dos requisitos que se notan visualmente si faltan (un modal sin role "se ve" igual, pero uno que no cierra al hacer click afuera se siente raro de inmediato, así que alguien probándolo a simple vista lo habría notado y arreglado). Los cinco que fallan —aria-labelledby, el foco inicial, el atrapamiento de Tab, Escape, el retorno del foco— son exactamente los que no se notan usando mouse y ojos: solo aparecen si pruebas con teclado (Tab, Escape) o con un lector de pantalla. Nadie los "vio faltar" porque nadie los probó con las herramientas que los revelan — el mismo patrón de la cerradura casera, que pasa la prueba visual y falla el protocolo de laboratorio.
La versión sobre Radix pasa las siete, y fíjate en algo importante: la apariencia no cambió. Las clases bg-surface text-foreground rounded-md p-4 son idénticas en los dos componentes — los mismos tokens de la lección 2, las mismas utilidades de la lección 3. Lo único que cambió es qué maneja el comportamiento: en el casero, nadie; en el de Radix, la primitiva. Dialog.Root, Dialog.Content y Dialog.Title no son componentes con estilo propio —son sin estilo, como el módulo 7 los describió—, así que vestirlos con tus tokens es tan directo como vestir un <div>. Ganas los siete puntos del checklist sin escribir una sola línea de manejo de foco o teclado.
Profundización: por qué el foco y el teclado son tan fáciles de "olvidar" y tan caros de reconstruir bien
Hay una razón estructural por la que el Dialog casero llega a 2/7 y no a 0/7 ni a 7/7: los requisitos que pasa son los que un desarrollador que prueba con mouse puede ver fallar y corrige por instinto; los que falla son invisibles con esa misma herramienta de prueba. Construir el atrapamiento de foco correctamente —que Tab cicle entre los elementos enfocables dentro del diálogo sin escapar hacia el resto de la página, incluyendo el caso de Shift+Tab hacia atrás en el primer elemento— no es una línea de código, es un manejador de eventos que rastrea el elemento activo, calcula cuáles son los elementos enfocables del contenedor, y intercepta el evento de teclado para redirigir el foco cuando corresponde. Es exactamente el tipo de lógica que Radix (y las decenas de primitivas que siguen el mismo patrón) ya escribió, probó contra lectores de pantalla reales, y mantiene actualizada contra los cambios de cada navegador. Reescribirla no te hace dueño de "más código tuyo" — te hace responsable de mantener, a mano, un mecanismo que un ecosistema entero ya resolvió y sigue resolviendo.
Errores comunes
Confundir "se ve como un modal" con "funciona como un modal". Qué pasa: se revisa el Dialog casero abriendo y cerrando con el mouse, se ve correcto, y se da por terminado. Por qué pasa: la apariencia y el comportamiento accesible son ortogonales — un componente puede verse perfecto y fallar cinco de siete requisitos de accesibilidad, como el ejemplo de esta lección. Cómo detectarlo: correr el checklist completo, o simplemente intentar navegar el Dialog casero solo con teclado (Tab, Escape, sin tocar el mouse) — el 2/7 se siente de inmediato. Cómo corregirlo: la apariencia se verifica con los ojos; el comportamiento se verifica con el checklist —o mejor, delegando el comportamiento a una primitiva que ya lo tiene resuelto y probado—.
Pensar que usar una primitiva significa perder control sobre el estilo. Qué pasa: se evita Radix (o cualquier primitiva) por la idea de que "va a verse genérico" o que "no se puede personalizar". Por qué pasa: la confusión entre primitivas sin estilo (Radix) y librerías de componentes con estilo propio (por ejemplo, un kit de UI con su propio look fijo). Cómo detectarlo: comparas las clases del Dialog casero y el de Radix en esta misma lección — son las mismas siete utilidades de Tailwind (bg-surface, text-foreground, rounded-md, p-4) en los dos—. Cómo corregirlo: una primitiva como Radix Dialog no impone ningún estilo — Dialog.Content es, visualmente, un <div> en blanco hasta que le pones tus propias clases. Ganas la accesibilidad sin ceder ni un pixel de control sobre la apariencia.
Auditar solo una vez, al construir, y no cuando el diseño cambia. Qué pasa: se corre a11yAudit cuando el Dialog se construye por primera vez, sale 7/7, y nunca se vuelve a correr — hasta que alguien, meses después, agrega un elemento enfocable nuevo dentro del Dialog.Content (por ejemplo, un segundo botón) de una forma que rompe el foco trap. Por qué pasa: se trata la accesibilidad como un checkbox de "hecho una vez", no como una propiedad que un cambio de código puede romper. Cómo detectarlo: no hay una forma de detectarlo sin volver a auditar — que es exactamente el punto. Cómo corregirlo: el checklist de a11yAudit es una herramienta que se corre cada vez que el componente cambia de forma relevante, igual que la auditoría de contraste de la lección 4 se corre cada vez que un color cambia — no es un sello que se estampa una sola vez.
Ejercicios
Ejercicio 1 — Audita un tercer diseño intermedio. Un desarrollador mejora el Dialog casero agregando aria-modal="true" y un useEffect que hace firstFocusableElement.focus() al abrir, pero no toca nada más. ¿Qué puntaje sacaría en a11yAudit? Constrúyelo y verifícalo.
Ver solución
const mejoradoImpl = {
role: true, // ya lo tenia
labelledby: false, // aria-modal no es lo mismo que aria-labelledby; sigue sin titulo conectado
initialFocus: true, // el useEffect nuevo lo resuelve
focusTrap: false, // mover el foco al abrir no es lo mismo que atraparlo en cada Tab
escapeCloses: false, // sigue sin listener de teclado
returnFocus: false, // sigue sin restaurar el foco al cerrar
clickOutside: true, // ya lo tenia
};
console.log(a11yAudit('QuickViewDialog mejorado', mejoradoImpl).passed); // 3
Sube de 2/7 a 3/7 — un punto más, por el foco inicial. La lección: cada requisito del checklist es independiente, y mejorar uno (mover el foco al abrir) no arregla los relacionados pero distintos (atraparlo mientras el diálogo está abierto, o devolverlo al cerrar). Son tres mecanismos de foco distintos, no uno solo — otra razón por la que reconstruirlos todos, a mano y de a uno, es tan propenso a quedar a medias.
Ejercicio 2 — Aplica el mismo patrón a un Menu. El menú de cuenta de usuario de Mercado (que se abre al hacer clic en el avatar) tiene los mismos riesgos que el Dialog: foco, teclado, roles. Nombra, sin escribir código, qué primitiva de Radix usarías y qué tres ítems del checklist de siete (adaptados a un menú, no a un diálogo) seguirían siendo relevantes.
Ver solución
La primitiva es @radix-ui/react-dropdown-menu (DropdownMenu.Root, DropdownMenu.Trigger, DropdownMenu.Content, DropdownMenu.Item) — el mismo patrón de "sin estilo, con la accesibilidad resuelta" que Dialog. De los siete ítems, los que siguen aplicando (adaptados) son: el rol correcto (role="menu" en el contenedor, role="menuitem" en cada opción, en vez de role="dialog"), la navegación por teclado (en un menú, las flechas arriba/abajo mueven el foco entre ítems, no solo Tab), y el retorno del foco (al cerrar el menú, ya sea eligiendo una opción o con Escape, el foco vuelve al botón que lo abrió — igual que en el Dialog). Los que no aplican igual son "click fuera cierra" (sí aplica, casi sin cambios) y "atrapar el foco dentro" (en un menú desplegable, normalmente Escape o click afuera cierran en vez de atrapar el foco indefinidamente). La lección: el checklist de siete de esta lección es específico de Dialog; cada tipo de primitiva (menú, tooltip, combobox) tiene su propio checklist de accesibilidad, con requisitos parecidos mas no idénticos.
Ejercicio 3 — Decide: construir o usar la primitiva. Mercado necesita un tooltip simple que solo muestra texto al pasar el mouse sobre un ícono de ayuda, sin ningún elemento interactivo dentro. ¿Vale la pena una primitiva de Radix para esto, o se justifica construirlo a mano? Usa el criterio del módulo 7 ("cuándo usar una librería y cuándo construir").
Ver solución
Depende del nivel de accesibilidad que ese tooltip necesite, pero incluso un tooltip "simple" tiene sorpresas: debe aparecer también con foco de teclado (no solo hover de mouse, para quien navega sin mouse), debe anunciarse a un lector de pantalla (aria-describedby conectando el ícono con el texto), y debe desaparecer con Escape o al perder el foco. Ninguno de esos tres requisitos es visualmente obvio si solo pruebas con mouse — el mismo patrón de esta lección—. El criterio del módulo 7 es: si el componente tiene algún requisito de foco, teclado o anuncio a lectores de pantalla —y un tooltip que debe funcionar con foco de teclado los tiene—, la primitiva (@radix-ui/react-tooltip) vale la pena, aunque el componente "se vea simple". Reservarías construir a mano solo para elementos puramente visuales, sin ninguna interacción de teclado ni necesidad de anunciarse — por ejemplo, un divisor decorativo entre secciones.
Resumen y siguiente paso
En esta lección construiste el mismo QuickViewDialog de Mercado dos veces — una a mano, otra sobre una primitiva accesible — y los auditaste con a11yAudit contra un checklist fijo de siete requisitos. El casero pasó 2/7 (role y "click afuera cierra", los dos que un vistazo con mouse revela si faltan); el de Radix pasó 7/7, con la misma apariencia exacta —los mismos tokens y utilidades de las lecciones 2 y 3—, porque la primitiva resuelve el comportamiento y tú solo la vistes. Con la cerradura certificada frente a la casera entendiste por qué la diferencia entre las dos versiones no se ve a simple vista — se mide con el protocolo correcto, y solo entonces aparece.
Antes de avanzar deberías poder: nombrar los siete requisitos del checklist de un Dialog accesible; explicar por qué "se ve bien" y "funciona con teclado" son verificaciones distintas; y decir por qué montar un componente sobre una primitiva no te hace perder control sobre su apariencia.
La lección 8 —la última— junta las seis capas que construiste en este módulo en una sola corrida de Node: tokens → utilidades → contraste → variantes → responsive/dark → primitivas, sobre el sistema completo de Mercado, con salida encadenada de punta a punta. Es el entregable final del capstone, y el cierre de toda la guía.
Recursos
- Radix Primitives, "Dialog" — radix-ui.com/primitives/docs/components/dialog. La primitiva real detrás del
QuickViewDialogde esta lección — foco, teclado y roles ya resueltos. En inglés. - Radix Primitives, "Dropdown Menu" — radix-ui.com/primitives/docs/components/dropdown-menu. La primitiva del ejercicio 2, para el menú de cuenta de Mercado. En inglés.
- ui.shadcn.com, "Dialog" — ui.shadcn.com/docs/components/dialog. El mismo
Dialogde Radix, ya vestido con un sistema de tokens de producción — el espejo real de lo que construiste. En inglés. - WAI-ARIA Authoring Practices Guide, "Dialog (Modal) Pattern" — w3.org/WAI/ARIA/apg/patterns/dialog-modal. El documento normativo detrás de los siete requisitos del checklist — la fuente de verdad que Radix implementa. En inglés.