Módulo 7: Primitives And Component Libraries

Primitivas sin estilo pero accesibles

Descripción

Las dos lecciones anteriores establecieron el problema: hay comportamiento accesible —rol, foco, teclado, estado— que es invisible desde la pantalla y fácil de olvidar si lo construyes a mano. Esta lección abre la solución concreta: cómo está hecha, por dentro, una primitiva real. Vas a conocer el modelo de Radix —la librería de primitivas más usada en el ecosistema React, y la que shadcn/ui usa por debajo— y su patrón de componente compuesto: en vez de un solo <Dialog> monolítico con quince props, un Dialog de Radix es varias piezas con nombre (Dialog.Root, Dialog.Trigger, Dialog.Content...) que trabajas juntas, cada una con una responsabilidad de comportamiento exacta y ninguna con una sola clase de CSS.

Conexión con el módulo. Esta lección responde "¿cómo se ve por dentro una primitiva?" antes de que la lección 4 te enseñe a vestirla. No ejecuta a11yAudit() de nuevo —eso ya lo hiciste dos veces—; en cambio, recorre la anatomía real de Dialog.* de Radix, la misma primitiva que vas a auditar a fondo en la lección 7 y a usar en el proyecto (L8). Entender las piezas ahora hace que vestirlas (L4) y decidir cuándo usarlas (L6) tengan un sujeto concreto, no abstracto.

Una analogía: el kit de piezas de un mueble modular

Piensa en un mueble modular de esos que se arman por partes: no es un bloque sólido, es un kit con piezas identificadas —los rieles del cajón, las bisagras de la puerta, la estructura del marco—. Cada pieza resuelve un problema mecánico específico: los rieles hacen que el cajón deslice sin trabarse y sin salirse del todo; las bisagras hacen que la puerta cierre con el ángulo correcto y no se caiga. Ninguna pieza del kit viene pintada ni con el acabado final —esa decisión (el color, el tipo de madera, el tirador) la tomas tú al ensamblarlo en tu casa—.

Una primitiva de Radix es ese kit. Dialog.Root es el marco que sostiene el estado (abierto/cerrado). Dialog.Trigger es la manija: el elemento que, al presionarse, abre el mueble —y ya viene con las bisagras de foco y teclado resueltas—. Dialog.Content son los rieles: la pieza que se encarga de que, una vez abierto, el foco no se salga del compartimento. Ninguna de estas piezas trae pintura. Vienen con la mecánica resuelta —el problema difícil, el que si lo haces mal el cajón se traba o se cae— y esperan que tú les pongas el acabado.

Guarda la imagen: una primitiva no es un componente terminado; es un kit de piezas con nombre, cada una resolviendo un problema de comportamiento específico, todas sin pintar.

Ejemplo trabajado: las ocho piezas de un Dialog

Así se ve un Dialog de Radix ensamblado, sin una sola clase de estilo todavía —esto es JSX real, lo que escribirías en tu editor—:

// Dialog.jsx — el kit de piezas de Radix, sin una sola clase de CSS.
import * as Dialog from '@radix-ui/react-dialog';

function CartDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Ver carrito</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title>Tu carrito</Dialog.Title>
          <Dialog.Description>3 productos, $459.00 MXN</Dialog.Description>
          {/* ... items del carrito ... */}
          <Dialog.Close>Cerrar</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Fíjate en algo antes de seguir: ni una sola clase. Este componente, tal como está, ya abre y cierra correctamente, ya atrapa el foco, ya cierra con Escape, ya anuncia su título a un lector de pantalla. Y en el navegador se ve completamente sin estilo —texto plano, sin fondo, sin sombra, superpuesto de forma cruda—. Eso es intencional: la lección 4 se encarga de vestirlo.

Para entender qué hace cada pieza sin necesidad de leer el código fuente de Radix, ejecutamos un mapa —dato fijo, no una llamada real a la librería— con la responsabilidad de cada una:

// L3 - anatomia de una primitiva: las piezas de un Dialog de Radix y de que se encarga cada una.
// (dato fijo, no es una llamada real a Radix: es el mapa mental de la composicion)

const dialogParts = [
  { part: 'Dialog.Root',        owns: 'estado abierto/cerrado (open, onOpenChange)' },
  { part: 'Dialog.Trigger',     owns: 'el <button> que abre; ya es focuseable y activa con Enter/Space' },
  { part: 'Dialog.Portal',      owns: 'renderiza el contenido al final del <body> (evita overflow/z-index)' },
  { part: 'Dialog.Overlay',     owns: 'el fondo oscurecido; bloquea clicks fuera del dialogo' },
  { part: 'Dialog.Content',     owns: 'role="dialog", aria-modal="true", focus-trap y Escape para cerrar' },
  { part: 'Dialog.Title',       owns: 'conecta con aria-labelledby: el nombre accesible del dialogo' },
  { part: 'Dialog.Description', owns: 'conecta con aria-describedby: la descripcion accesible' },
  { part: 'Dialog.Close',       owns: 'boton de cierre; devuelve el foco al Trigger al activarse' },
];

console.log('=== anatomia de una primitiva: Dialog.* y de que se encarga cada parte ===\n');
console.log('parte'.padEnd(22) + 'de que se encarga (sin escribir una linea de JS)');
console.log('-'.repeat(78));
for (const { part, owns } of dialogParts) {
  console.log(part.padEnd(22) + owns);
}
console.log('\nninguna de estas ocho piezas trae una sola clase de CSS.');

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

=== anatomia de una primitiva: Dialog.* y de que se encarga cada parte ===

parte                 de que se encarga (sin escribir una linea de JS)
------------------------------------------------------------------------------
Dialog.Root           estado abierto/cerrado (open, onOpenChange)
Dialog.Trigger        el <button> que abre; ya es focuseable y activa con Enter/Space
Dialog.Portal         renderiza el contenido al final del <body> (evita overflow/z-index)
Dialog.Overlay        el fondo oscurecido; bloquea clicks fuera del dialogo
Dialog.Content        role="dialog", aria-modal="true", focus-trap y Escape para cerrar
Dialog.Title          conecta con aria-labelledby: el nombre accesible del dialogo
Dialog.Description    conecta con aria-describedby: la descripcion accesible
Dialog.Close          boton de cierre; devuelve el foco al Trigger al activarse

ninguna de estas ocho piezas trae una sola clase de CSS.

Lee la tabla como un mapa de responsabilidades, no como una lista de nombres para memorizar. Cada fila resuelve un problema —y varios de esos problemas son justo los que la lección 1 anticipó y la lección 2 mostró que son invisibles a simple vista—. Dialog.Trigger resuelve lo que en la lección 1 medía a11yAudit() para un Button (role, focusable, activate): no es un componente distinto del Button del módulo 5, es el mismo tipo de elemento, pero Radix garantiza que sea un <button> real por debajo, no un <div>. Dialog.Content resuelve las tres casillas más difíciles de la lección 7 (role, focusTrap, escape) en una sola pieza. Dialog.Portal resuelve un problema que ni siquiera es de accesibilidad —el z-index y el overflow: hidden de un contenedor padre que recortaría el modal— pero que en la práctica es la razón número uno por la que un modal casero se ve roto en producción aunque funcione perfecto en desarrollo.

Y el Dialog.Trigger merece una segunda lectura: es un <button> real por debajo, no un <div> disfrazado. Eso significa que cuando lo compones con tu propio Button del módulo 5 —algo que vas a hacer en el proyecto (L8)— usas la prop asChild (que ya viste de pasada en el módulo 5, lección 7) para que Radix le pase su comportamiento a tu elemento en vez de renderizar uno propio:

// Dialog.Trigger con asChild: el comportamiento de Radix, sobre TU Button del modulo 5.
<Dialog.Trigger asChild>
  <Button variant="primary" size="lg">Ver carrito</Button>
</Dialog.Trigger>

Sin asChild, tendrías dos elementos anidados (<button><button>Ver carrito</button></button>) — HTML inválido y confuso para el árbol de accesibilidad. Con asChild, Radix le "presta" su comportamiento de trigger directamente al Button que ya construiste, sin duplicar el elemento. Es la misma composición por slots que estudiaste en el módulo 5; aquí el slot que compones no es tuyo, es de la primitiva.

Profundización: data-state — el gancho entre comportamiento y estilo

Hay una pieza del rompecabezas que todavía no mencionamos: si la primitiva no trae estilos, ¿cómo le pones, por ejemplo, una animación de entrada cuando el diálogo se abre? La respuesta es un patrón que Radix usa consistentemente en todas sus primitivas: cada pieza expone su estado interno como un atributo data-* en el DOM, no como una clase. Dialog.Content se renderiza con data-state="open" cuando está abierto y data-state="closed" cuando está cerrando (durante la animación de salida). Tú lees ese atributo desde tus clases de Tailwind:

// data-state lo pone Radix; tu lo lees con el selector de atributo de Tailwind.
<Dialog.Content className="data-[state=open]:animate-in data-[state=closed]:animate-out">

Esto es exactamente el mismo principio que ya usaste con dark: en el módulo 6: una condición que vive fuera de tu control directo (ahí, el tema; aquí, el estado de la primitiva) se expone como algo que tus utilidades de Tailwind pueden leer y responder, sin que tú tengas que sincronizar manualmente una clase con un useState. El comportamiento (¿está abierto?) lo decide la primitiva; la respuesta visual (¿cómo se anima al abrir?) la decides tú, leyendo lo que la primitiva expone. Ningún componente de Mercado necesita un useEffect para sincronizar "abrí el modal" con "ponle esta clase" — Radix ya puso el dato en el DOM, listo para que Tailwind lo lea.

El contrato de una primitiva:

  compone[Root, Trigger, Content...]  →  comportamiento (foco, teclado, roles)
                                          + data-state en el DOM
                                                   │
                                                   ▼
                          tus clases de Tailwind leen data-[state=...]
                                                   │
                                                   ▼
                              apariencia 100% tuya, comportamiento 0% tuyo

Errores comunes

Buscar una sola prop de estilo dentro de la primitiva. Qué pasa: alguien revisa la documentación de Dialog.Content buscando una prop color o backgroundColor y no la encuentra, y concluye que "algo está mal" o que "Radix está incompleto". Por qué pasa: la mayoría de las librerías de UI que existen (Material UI, Bootstrap, Chakra) sí tienen props de estilo, y ese hábito se transfiere. Cómo detectarlo: estás buscando en los docs de una primitiva algo que suena a apariencia, no a comportamiento. Cómo corregirlo: una primitiva no tiene props de estilo porque ese no es su trabajo — el estilo lo pones tú con className, exactamente como en cualquier elemento HTML. Si buscas color en Radix y no aparece, no es un vacío: es el diseño funcionando como se espera.

Renderizar Dialog.Content sin Dialog.Portal, y sorprenderse de que se vea cortado. Qué pasa: se omite Dialog.Portal "para simplificar" y el modal aparece recortado por el overflow: hidden de una tarjeta padre, o detrás de otro elemento con z-index más alto. Por qué pasa: Portal no parece tener relación obvia con accesibilidad, así que se siente opcional. Cómo detectarlo: tu modal se ve bien en una página pero roto dentro de una tarjeta o un contenedor con scroll. Cómo corregirlo: Dialog.Portal renderiza el contenido al final del <body>, fuera de cualquier contenedor con overflow o z-index restrictivo — es la pieza que evita ese bug de raíz. Inclúyela siempre que uses Dialog.Content, aunque hoy tu layout no muestre el problema; aparecerá el día que alguien anide el diálogo dentro de otro contenedor.

Olvidar Dialog.Title y romper el nombre accesible. Qué pasa: se arma el Content con texto e íconos libres, sin usar Dialog.Title, porque visualmente "ya tiene un título" (un <h2> cualquiera arriba). Por qué pasa: visualmente cumple —hay texto grande arriba que se lee como título—. Cómo detectarlo: un lector de pantalla anuncia "diálogo" sin nombre al abrir el modal, en vez de "diálogo: tu carrito". Cómo corregirlo: Dialog.Title no es solo una etiqueta semántica bonita — Radix la conecta automáticamente con aria-labelledby en Dialog.Content, que es lo que hace que el nombre se anuncie. Un <h2> suelto, sin pasar por Dialog.Title, no queda conectado, y el diálogo se abre "sin nombre" para quien no lo ve.

Ejercicios

Ejercicio 1 — Asigna la pieza. Para cada responsabilidad, di qué pieza del Dialog de Radix la resuelve:

  • (a) Que el modal no quede recortado por el overflow: hidden de una tarjeta.
  • (b) Que un lector de pantalla anuncie "tu carrito" al abrir el modal.
  • (c) Que hacer clic afuera del modal lo cierre.
  • (d) Que el <button> que abre el modal ya sea focuseable y active con Enter/Space.
Ver solución
  • (a) Dialog.Portal. Renderiza fuera del árbol del contenedor padre, esquivando su overflow/z-index.
  • (b) Dialog.Title. Se conecta por aria-labelledby con Dialog.Content para dar el nombre accesible.
  • (c) Dialog.Overlay. El fondo oscurecido es también el que captura el clic "afuera" y lo traduce en cierre.
  • (d) Dialog.Trigger. Es un <button> real por debajo; el foco y la activación por teclado vienen del elemento nativo.

Ejercicio 2 — asChild o no. Tienes este JSX. ¿Está bien o produce un problema? Si hay un problema, corrígelo.

<Dialog.Trigger>
  <Button variant="primary">Ver carrito</Button>
</Dialog.Trigger>
Ver solución

Tiene un problema. Sin asChild, Dialog.Trigger renderiza su propio <button> por default, y como Button también renderiza un <button>, el resultado es <button><button>...</button></button> — un botón anidado dentro de otro, HTML inválido y confuso para el árbol de accesibilidad (los navegadores manejan esto de forma inconsistente, y algunos lectores de pantalla anuncian el elemento interno duplicado o ninguno). La corrección es agregar asChild:

<Dialog.Trigger asChild>
  <Button variant="primary">Ver carrito</Button>
</Dialog.Trigger>

Con asChild, Radix no renderiza su propio elemento: le pasa su comportamiento de trigger (el manejo de clic que abre el diálogo) directamente al Button que le diste, y el resultado final es un solo <button> en el DOM.

Ejercicio 3 — data-state sin useState. Un compañero pregunta: "¿cómo sabe mi CSS si el diálogo está abierto o cerrado, si yo no manejo ningún estado de React para eso?" Responde en un par de frases.

Ver solución

No necesitas manejar ese estado tú mismo porque Dialog.Root ya lo maneja internamente (abierto/cerrado), y Dialog.Content lo expone en el DOM como el atributo data-state="open" o data-state="closed". Tu CSS —con el selector de atributo de Tailwind, data-[state=open]:...— lee ese atributo directamente del DOM cada vez que cambia, sin que tú sincronices nada con useState o useEffect. Es el mismo patrón que la clase .dark del módulo 6: una condición externa a tu componente (ahí el tema, aquí el estado de la primitiva) que tus utilidades leen, no que tú programas.

Resumen y siguiente paso

En esta lección abriste la caja de una primitiva real: Radix compone un widget complejo en piezas con nombre —Root, Trigger, Portal, Overlay, Content, Title, Description, Close—, cada una resolviendo un problema de comportamiento específico, y ninguna trayendo una sola clase de estilo. Con el kit de mueble modular viste por qué: cada pieza resuelve la mecánica difícil (las bisagras, los rieles) y espera que tú le pongas el acabado. Y viste el mecanismo que conecta comportamiento con estilo sin useState: los atributos data-state, que tus clases de Tailwind leen directamente.

Antes de avanzar deberías poder: nombrar al menos cinco piezas de Dialog.* y qué resuelve cada una; explicar para qué sirve asChild con tus propias palabras; y decir qué es data-state y por qué te ahorra sincronizar estado a mano.

Tienes el kit ensamblado, pero sin pintar —tal como lo viste en el JSX de esta lección—. La lección 4 le pone el acabado: vas a vestir Dialog.Content con los tokens y el variants() de Mercado que ya dominas del módulo 5, y vas a comprobar, ejecutando, que vestir una primitiva no es distinto de vestir cualquier otro componente.

Recursos