Módulo 7: Primitives And Component Libraries

La accesibilidad que no se ve

Descripción

La lección 1 midió un Button casero contra uno real y encontró una diferencia de 0/3 contra 3/3 en tres casillas —rol, foco, activación por teclado—. Pero un botón, al menos, se comporta distinto a simple vista: uno responde al Tab, el otro no. Esta lección lleva la idea a un caso más incómodo, donde ni siquiera eso es cierto: un checkbox casero y uno accesible pueden verse exactamente igual en pantalla —el mismo cuadrito, la misma marca cuando está activado— y aun así uno es utilizable con teclado y lector de pantalla, y el otro es invisible para esas dos formas de navegar. La accesibilidad de comportamiento no vive en el píxel; vive en un árbol paralelo que el navegador construye para que el sistema operativo y las tecnologías de asistencia lo lean, y ese árbol no aparece en ningún inspector visual.

Conexión con el módulo. Esta lección es el "por qué importa" antes de que la lección 3 entre a la anatomía de una primitiva real. Reusa a11yAudit() tal cual se definió en la lección 1 —mismo checklist, sin cambios— y lo aplica a un segundo widget, un checkbox, para mostrar el caso límite: donde lo visual miente. Sin entender que este problema es invisible por diseño, la razón de ser de las primitivas (lecciones 3-7) suena a exceso de precaución; con esta lección, se entiende como la respuesta a un riesgo real y medible.

Una analogía: el iceberg

Un iceberg muestra, sobre la superficie del agua, una fracción pequeña de sí mismo —una loma de hielo, quizás del tamaño de un edificio—. Debajo del agua, invisible desde la cubierta de un barco, sigue una masa varias veces más grande, que es la que de verdad determina si el barco pasa sin problema o encalla. Un capitán que navega mirando solo lo que sobresale del agua está navegando con información incompleta —y el Titanic es el recordatorio histórico de qué tan caro sale eso—.

Un componente de interfaz tiene la misma estructura. Lo que "sobresale del agua" es el render visual: el cuadrito del checkbox, el color cuando está marcado, la animación al hacer clic. Eso es lo que ves en la pantalla, lo que revisas en un diseño, lo que un screenshot captura perfecto. Debajo, invisible para cualquiera que solo mire la pantalla, está el árbol de accesibilidad: el rol que el navegador le asigna al elemento (checkbox, button, dialog...), si es alcanzable con Tab, qué pasa al presionar una tecla, y qué estado expone (aria-checked="true") para que un lector de pantalla pueda anunciar "casilla, marcada" en vez de quedarse mudo frente a un <div> con una clase CSS. Ese árbol no aparece en un diseño de Figma ni en un screenshot; aparece solo si lo inspeccionas con las herramientas correctas (el panel de accesibilidad de las devtools del navegador) o si lo navegas sin mouse.

Por eso el error de construir un checkbox con un <div onClick> es tan fácil de cometer y tan difícil de detectar en una revisión normal: visualmente, el iceberg se ve idéntico sobre el agua. La diferencia está entera bajo la superficie.

Guarda la imagen: lo que ves en pantalla es la parte visible del iceberg; el comportamiento accesible —rol, foco, teclado, estado— es la masa invisible debajo, y es la que decide si el componente funciona para quien no navega con mouse.

Ejemplo trabajado: el mismo , dos árboles de accesibilidad

Construimos dos versiones de un checkbox de Mercado —pensemos en "aplicar filtro: solo con envío gratis" del catálogo—. Las dos, cuando están activadas, muestran el mismo símbolo sobre el mismo cuadrito con los mismos tokens de color. La diferencia completa está en lo que no se ve. Reusamos a11yAudit() de la lección 1, sin tocarlo, y le damos cuatro casillas aplicables a un checkbox: rol, foco, activación y estado (aria-checked) —esta última es la que el botón simple de la lección 1 no necesitaba, porque un botón no tiene un estado de "marcado" que comunicar—.

// L2 - la accesibilidad que no se ve: un checkbox que SE VE igual para los dos,
// pero uno es invisible para quien usa teclado o lector de pantalla.
// a11yAudit() es el mismo checklist de L1 (definicion completa, sin cambios).

const CHECKS = [
  { key: 'role',      label: 'rol ARIA correcto' },
  { key: 'focusable',  label: 'focuseable con Tab' },
  { key: 'activate',  label: 'Enter/Space activa' },
  { key: 'escape',    label: 'Escape cierra/cancela' },
  { key: 'arrows',    label: 'flechas navegan entre opciones' },
  { key: 'focusTrap', label: 'foco atrapado dentro (focus-trap)' },
  { key: 'ariaState', label: 'expone estado por aria-* (aria-checked, aria-expanded...)' },
];

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 };
}

// visualmente, los dos muestran un check (✓) cuando estan marcados. eso es lo que SE VE.
const homemadeCheckbox = {
  name: 'Checkbox casero (<div onClick> + clase CSS del check)',
  applicable: ['role', 'focusable', 'activate', 'ariaState'],
  pass: [], // el check se pinta con CSS; nada de eso llega al arbol de accesibilidad
};

const primitiveCheckbox = {
  name: 'Checkbox sobre una primitiva (role="checkbox")',
  applicable: ['role', 'focusable', 'activate', 'ariaState'],
  pass: ['role', 'focusable', 'activate', 'ariaState'],
};

console.log('=== a11yAudit: el mismo check (✓) visual, dos arboles de accesibilidad distintos ===\n');
for (const c of [homemadeCheckbox, primitiveCheckbox]) {
  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: el mismo check (✓) visual, dos arboles de accesibilidad distintos ===

Checkbox casero (<div onClick> + clase CSS del check): 0/4
  falta: rol ARIA correcto, focuseable con Tab, Enter/Space activa, expone estado por aria-* (aria-checked, aria-expanded...)
Checkbox sobre una primitiva (role="checkbox"): 4/4

Fíjate en el comentario del código, no solo en la salida: dice explícitamente que el visual es idéntico entre las dos versiones —la diferencia no está ahí—. El checkbox casero pierde las cuatro casillas, y las cuatro son invisibles desde la pantalla. No tiene role="checkbox", así que un lector de pantalla no sabe que ese <div> es una casilla de verificación —podría anunciarlo como nada, o como "grupo", o silenciarlo—. No es focuseable, así que alguien que navega solo con Tab (por elección, por una discapacidad motriz, o simplemente porque tiene el mouse ocupado) nunca llega a él. Su onClick no responde a Space —la tecla estándar para marcar un checkbox—, así que aunque por algún milagro llegara el foco, no habría forma de activarlo sin mouse. Y no expone aria-checked, así que ni siquiera si alguien lo "activa" de otra forma, hay manera de que un lector de pantalla sepa si quedó marcado o no —el estado vive únicamente en una clase CSS que pinta un ícono, y las clases CSS no hablan con el árbol de accesibilidad—.

El checkbox construido sobre una primitiva pasa las cuatro sin que hayas escrito una línea de manejo de teclado o de aria-checked a mano: eso es exactamente lo que la primitiva resuelve, y es la razón por la que el resto del módulo existe. La lección no es "los checkboxes caseros están mal hechos por descuido" —es que este tipo de bug es estructuralmente invisible en cualquier revisión que dependa de mirar la pantalla, incluida la tuya, incluido un QA visual completo, incluida una demo perfecta frente al cliente. Se necesita navegar sin mouse o inspeccionar el árbol de accesibilidad para encontrarlo —y para cuando alguien lo hace, si nadie lo hizo antes, ya está en producción.

Profundización: por qué existe un árbol paralelo, y qué expone

El navegador, además del DOM visual que renderiza en pantalla, construye un segundo árbol —la accessibility tree (árbol de accesibilidad)— que es lo que el sistema operativo entrega a las tecnologías de asistencia: lectores de pantalla (VoiceOver, NVDA, JAWS), software de reconocimiento de voz, teclados alternativos. Cada nodo de ese árbol tiene, como mínimo, un rol (¿qué tipo de cosa es esto: botón, checkbox, diálogo, menú?), un nombre accesible (¿cómo se llama esto, para que puedan anunciarlo: "Agregar al carrito", no "botón sin nombre"?) y, cuando aplica, un estado (¿está marcado?, ¿está expandido?, ¿está seleccionado?). Los elementos nativos del HTML —<button>, <input type="checkbox">, <a href>, <select>— llenan ese árbol automáticamente, porque el navegador ya sabe qué son. Un <div> no llena nada por default: para el árbol de accesibilidad, un <div> sin atributos es un contenedor sin nombre, sin rol, sin estado. Visualmente puede ser cualquier cosa —un botón, un checkbox, un menú—; para la accesibilidad, es nada, hasta que tú le agregas explícitamente role, tabindex, manejo de teclado y aria-*.

Esa es la raíz del problema que este módulo resuelve. Cuando construyes con elementos nativos (<button>, <input>) el árbol se llena solo. Cuando necesitas un componente que el HTML no ofrece —un Dialog, un DropdownMenu, un Combobox con autocompletado— no hay un elemento nativo que lo resuelva, y la tentación es construirlo con <div>s y estilos, sin darle al árbol de accesibilidad nada de lo que necesita. Una primitiva hace exactamente ese trabajo por ti: agrega el role, gestiona el tabindex, escucha las teclas correctas y actualiza los aria-* en el momento correcto —para que el árbol paralelo quede tan completo como si hubieras usado un elemento nativo, aun cuando el HTML no tiene uno para lo que estás construyendo—.

Una nota de honestidad, para no sobre-prometer: a11yAudit() es un modelo tuyo, con datos que tú describes a mano (pass: [...]) — no detecta automáticamente si un componente real cumple cada casilla; eso lo hacen herramientas reales (axe DevTools, el panel de Accessibility de Chrome DevTools, o navegar con un lector de pantalla real). Lo que a11yAudit() te da es el vocabulario —las siete casillas concretas en las que pensar— y la disciplina de nombrarlas explícitamente para cada componente, en vez de asumir "seguro está bien". Esa disciplina es transferible: cuando construyas un componente real, vas a saber qué preguntarte, aunque la respuesta la verifiques con otra herramienta.

Dos arboles, un mismo pixel:

  <div onClick>                    <button role="checkbox"
   className="checked">             aria-checked="true">
        │                                  │
   DOM visual: ✓ pintado             DOM visual: ✓ pintado
   (identico en pantalla)            (identico en pantalla)
        │                                  │
   accessibility tree:               accessibility tree:
   (vacio - sin rol,                 role: checkbox
    sin estado, sin nombre)          state: checked
        │                                  │
   lector de pantalla:                lector de pantalla:
   silencio, o "grupo"                "envio gratis, casilla,
                                       marcada"

Errores comunes

Revisar accesibilidad mirando solo la pantalla. Qué pasa: el proceso de QA de un componente nuevo consiste en abrirlo en el navegador y ver si "se ve bien". Por qué pasa: es el reflejo natural —revisamos con los ojos porque construimos con los ojos—. Cómo detectarlo: en tu checklist de revisión no hay ningún paso que diga "navega esto solo con teclado" o "ábrelo con un lector de pantalla". Cómo corregirlo: agrega un paso deliberado —suelta el mouse, navega con Tab/Enter/Space/Escape/flechas, y confirma que llegas a todo y que todo responde—. Es gratis, toma dos minutos, y es la única forma de encontrar el bug que esta lección describe antes de que lo encuentre un usuario real.

Confundir "tiene un ícono de check" con "expone su estado". Qué pasa: se agrega una clase CSS que muestra un ícono cuando el checkbox está "marcado", y se da por resuelto el estado. Por qué pasa: visualmente cumple —se ve marcado cuando está marcado—. Cómo detectarlo: pregúntate "¿cómo se entera un lector de pantalla de que esto está marcado?" — si la respuesta es "mirando el ícono", ya sabes que no se entera. Cómo corregirlo: el estado tiene que vivir en un atributo que el árbol de accesibilidad lea —aria-checked="true", o el checked nativo de un <input type="checkbox">—, no solo en una clase que cambia el color de un ícono. La clase CSS es para los ojos; aria-checked es para el árbol.

Pensar que este problema solo afecta a "pocos usuarios" y por eso puede esperar. Qué pasa: se posterga arreglar un componente sin accesibilidad de comportamiento porque "casi nadie usa lector de pantalla". Por qué pasa: subestimar cuántas formas hay de no depender del mouse —discapacidad visual, discapacidad motriz, un mouse roto, un usuario power-user que prefiere el teclado, alguien navegando desde un dispositivo con teclado físico y pantalla táctil rota—. Cómo detectarlo: la conversación sobre accesibilidad se enmarca como "un caso especial" en vez de "una forma más de usar la interfaz". Cómo corregirlo: navegar sin mouse no es un caso de borde raro; es una de las formas normales en que la gente usa software, y en la mayoría de los países pasar el nivel AA (que incluye operabilidad por teclado) es, además, una obligación legal —el módulo 4 ya lo cubrió para el contraste; aquí es la misma lógica para el comportamiento—.

Ejercicios

Ejercicio 1 — Encuentra el iceberg. Para cada componente, di si el problema descrito es visible (se nota mirando la pantalla) o invisible (solo se nota navegando sin mouse o inspeccionando el árbol de accesibilidad):

  • (a) El botón "Comprar" no tiene suficiente contraste entre el texto y el fondo.
  • (b) El menú de "Ordenar por" no responde a las flechas del teclado.
  • (c) La tarjeta de producto se desborda del contenedor en pantallas chicas.
  • (d) El modal del carrito no anuncia su título a un lector de pantalla.
Ver solución
  • (a) Visible. El contraste bajo se nota a simple vista —de hecho es justamente lo que el módulo 4 enseñó a medir, pero el síntoma en sí se ve—.
  • (b) Invisible. Un menú se ve completo y funcional con mouse aunque las flechas no hagan nada; el problema solo aparece si intentas navegarlo con teclado.
  • (c) Visible. Un layout roto es un bug visual clásico —lo detecta cualquier revisión en pantalla, sin necesidad de tocar el teclado.
  • (d) Invisible. El título puede verse perfecto en pantalla y aun así no estar conectado por aria-labelledby; un usuario vidente nunca lo nota, un usuario de lector de pantalla sí, de inmediato.

La regla para distinguirlos: si el bug rompe el layout, el color o la forma, es visible. Si el bug rompe el foco, el teclado o el árbol de accesibilidad sin tocar cómo se ve, es invisible —y es el tipo de bug que este módulo entero existe para prevenir.

Ejercicio 2 — Predice el score. Sin correr nada: si al homemadeCheckbox del ejemplo trabajado le agregaras tabindex="0" (haciéndolo focuseable) pero sin tocar nada más —sigue sin role, sin manejo de Space, sin aria-checked—, ¿qué score esperarías, y qué casillas seguirían faltando?

Ver solución

1/4. Solo focusable pasaría (ahora sí entra en el orden de tabulación). Seguirían faltando role (sigue siendo un <div> sin role="checkbox", así que un lector de pantalla no sabe qué es, aunque ahora se pueda llegar a él con Tab), activate (Space sigue sin estar manejado; llegar con Tab no sirve si no puedes accionarlo) y ariaState (el estado sigue viviendo solo en una clase CSS). El ejercicio muestra algo importante: las casillas son independientes, no un combo que se resuelve junto. Agregar tabindex es un paso real, pero uno de cuatro —y llegar con Tab a un elemento que no puedes activar ni que anuncia su estado es, en la práctica, casi tan inútil como no llegar.

Ejercicio 3 — El árbol paralelo. Explica con tus palabras, en dos o tres frases, por qué un <div> con estilos que "se ve" como un checkbox puede tener el árbol de accesibilidad completamente vacío, mientras que un <input type="checkbox"> sin ningún CSS lo tiene completo.

Ver solución

El árbol de accesibilidad no se construye a partir de cómo se ve un elemento —del CSS—, sino de qué elemento HTML es y de los atributos ARIA que le agregas explícitamente. <input type="checkbox"> es un elemento que el navegador reconoce como checkbox por su semántica nativa, así que le asigna rol, foco y manejo de teclado automáticamente, sin que tú escribas nada — y eso es cierto incluso sin una sola línea de CSS, porque el árbol de accesibilidad no depende de la apariencia. Un <div>, en cambio, es semánticamente neutro: no importa qué tan parecido a un checkbox lo hagas ver con CSS, el navegador no tiene forma de saber que "es" un checkbox a menos que tú se lo digas con role, aria-checked y manejo de teclado explícito. El CSS pinta el iceberg sobre el agua; el HTML semántico (o el role/aria-* que lo imita) construye lo que hay debajo.

Resumen y siguiente paso

En esta lección profundizaste la tesis de la lección 1 con el caso límite: la accesibilidad de comportamiento es invisible desde la pantalla, porque vive en un árbol paralelo —el árbol de accesibilidad— que el navegador construye a partir de la semántica del elemento, no de su apariencia. Con el iceberg viste por qué un checkbox casero y uno accesible pueden ser indistinguibles a simple vista y aun así estar en polos opuestos de utilizabilidad. Y lo comprobaste ejecutando: el mismo visual, 0/4 contra 4/4 en un checklist que ninguna revisión visual habría encontrado.

Antes de avanzar deberías poder: explicar qué es el árbol de accesibilidad y por qué es distinto del DOM visual; nombrar al menos dos formas de "navegar sin mouse" que usarías para encontrar este tipo de bug; y decir por qué role/aria-checked no son "extra", son lo que llena ese árbol cuando usas un <div> en vez de un elemento nativo.

Ya sabes qué problema resuelve una primitiva y por qué es difícil de detectar a simple vista. La lección 3 abre la caja: la anatomía real de una primitiva, con Radix como el ejemplo concreto. Vas a ver cómo un Dialog se compone de piezas con nombre —Root, Trigger, Portal, Overlay, Content, Title, Description, Close— y de qué se encarga exactamente cada una, antes de tocar un solo token de estilo.

Recursos