Lección 14

¿Por qué el 15% de tus usuarios no puede usar lo que construiste?

La accesibilidad no es una capa que se pinta al final: es una decisión sobre el teclado, el foco y el texto. Y el texto, cuando se traduce, crece.

01Qué es accesibilidad y por qué es arquitectura

La accesibilidad es que la interfaz se pueda usar sin ratón, sin ver bien, sin oír y con poca memoria de trabajo. Se recorre con Tab, se entiende con un lector de pantalla (el programa que lee en voz alta lo que hay en pantalla) y tiene contraste suficiente.

La Organización Mundial de la Salud estima que ~16% de la población mundial vive con una discapacidad significativa. La cuenta real es mayor porque incluye lo temporal y lo situacional: el brazo enyesado, el sol en la pantalla, el bebé en el otro brazo. Casi todos pasamos por ahí.

Si la rampa de un edificio se dibuja después, termina en la puerta de servicio. En el plano es una línea; agregada al final, obliga a romper la escalera. Con la interfaz pasa igual: pensada con los componentes, un diálogo nace con su foco.

Y eso es arquitectura, no buena voluntad. El orden de tabulación sigue el orden del DOM (el árbol de elementos), así que una tarjeta que se ve arriba pero se escribió al final se recorre al revés; un diálogo de capas flotantes no tiene dónde encerrar el foco.

El parche que no pega

Un aria-label no arregla un div que no recibe foco, ni un texto alternativo un formulario que solo se envía con ratón. ARIA describe lo que existe; no crea comportamiento.

02Por qué sucede: foco, lectores, contraste y textos que crecen

El foco es el lugar que recibe lo que tecleas; se mueve con Tab y Shift+Tab. El orden lo decide el DOM: un tabindex="2" a mano rompe el recorrido. El foco tiene que verse; quitar el contorno sin reemplazarlo deja a quien navega con teclado a ciegas. En un diálogo, el foco entra al abrir, no se escapa y vuelve al botón al cerrar.

Un lector de pantalla anuncia nombre, rol y estado: “botón, Guardar cambios”. El nombre sale del texto visible, de un label asociado o de aria-label. Si el aria-label contradice el texto visible, el daño es peor que no ponerlo: quien usa control por voz dice “clic en Guardar” y el botón se llama “Enviar formulario”. Un div no responde a Enter.

Para avisar sin mover el foco están las regiones vivas: un contenedor con aria-live="polite" al que le cambias el texto y el lector lo lee al terminar la frase en curso. “3 resultados” tras filtrar. La región debe existir antes del cambio; si la insertas con el mensaje, muchos lectores no la anuncian.

El contraste se mide, no se aprecia: 4.5:1 para texto normal, 3:1 para texto grande y para bordes de campos y anillo de foco. A ojo, un verde oliva parece perfecto y da 3.8:1; el gris del placeholder suele quedarse en 2.5:1. Hay que revisar hover y modo oscuro.

En un formulario, la etiqueta va asociada al campo con for e id; el placeholder no es etiqueta porque desaparece al escribir. El error se anuncia, no solo se pinta: el campo lo referencia con aria-describedby, el resumen usa role="alert" y el mensaje dice qué corregir: “El código postal debe tener 5 dígitos”, no “Error”. Si el único indicador es rojo, no todos lo ven.

La internacionalización empieza por no concatenar. Pegar “Eliminar”, el número y “elementos” funciona en español y en inglés, pero en alemán el verbo va al final y en japonés el orden es otro; además el género cambia: “usuario nuevo”, “cuenta nueva”. El texto completo, con su hueco, es la unidad de traducción. Los plurales tampoco son una comparación con 1: el polaco distingue uno, pocos y muchos; el árabe tiene cero, uno, dos, pocos, muchos y otros. Eso lo resuelve Intl.PluralRules.

Fechas y números siguen la configuración regional: 03/04/2024 es 3 de abril en México y 4 de marzo en Estados Unidos. Se formatean con Intl.DateTimeFormat y Intl.NumberFormat. En idiomas de derecha a izquierda (RTL) el documento lleva dir="rtl" y los márgenes usan propiedades lógicas; las flechas de “atrás” se reflejan, los números no.

Y está el texto que crece: el alemán ocupa del orden de 30% más y sus palabras compuestas no se parten solas. Un botón de 120 px con “Guardar” se rompe con “Änderungen speichern”: el texto se sale o se corta. Se prueba con seudolocalización, 200% de zoom y 320 px. La otra fuente de bugs son las zonas horarias: guarda instantes UTC y formatea con la zona explícita; “mañana a las 9” no es sumar 24 horas, porque en el cambio de horario el día dura 23 o 25.

El mismo diálogo con el foco perdido y con el foco administrado mal: el foco se escapa menú tabla Editar dirección nombre correo Cancelar Guardar 1 2 3 4 el cuarto Tab cae detrás del diálogo bien: el ciclo se cierra menú tabla Editar Editar dirección nombre correo Cancelar Guardar 1 2 3 4 al cerrar, el foco vuelve al botón
El recorrido se dibuja con números: sin encierro, el cuarto Tab sale del diálogo; con encierro, el ciclo vuelve al primer campo y al cerrar regresa a “Editar”.
El mismo botón con dos idiomas es-MX Configuración de la cuenta Guardar cambios de-DE Benutzerkonten und Einstellungen Änderungen speichern +32% el texto cruza el borde del botón
El botón mide lo mismo en los dos idiomas; el texto no. El título alemán tampoco cabe en una línea y empuja la tarjeta, que es el siguiente desbordamiento.
El mismo 3.8:1 falla o pasa según el tamaño del texto texto normal · umbral 4.5:1 3.8:1 4.5:1 no pasa a ojo se ve perfecto texto grande · umbral 3:1 3.8:1 3:1 sí pasa mismo color, otro requisito bordes de campo y anillo de foco también piden 3:1 3.8 se ve bien y falla en texto normal: decide el número, no el ojo
El color no cambia; cambia el umbral. Por eso el contraste se mide también en hover, en modo oscuro y en el borde del campo, y no se aprueba porque “se vea bien”.

03Cómo se ataca: componentes, mensajes y pruebas

El orden es semántica primero: un button es un botón, un label una etiqueta, un dialog un diálogo. Después, teclado y foco; ARIA solo cuando el HTML no alcanza. Se sostiene haciendo que el componente correcto sea el fácil: un Dialog que encierra el foco y lo devuelve, un Field que asocia etiqueta y error.

Para los textos, un catálogo ICU fuera del código, la configuración regional del perfil y un respaldo explícito. El lang del documento (document.documentElement.lang) cambia con el idioma —una página en español que declare lang="en" hace que el lector pronuncie mal— y el dir con la dirección. Las fechas se guardan en UTC y se formatean con la zona de quien las lee.

El presupuesto de pruebas incluye tres guardarraíles baratos: una seudolocalización que alarga las cadenas, una revisión con axe y un recorrido de teclado en los diálogos. No sustituyen a una persona que use lector, pero atrapan lo que se rompe en cada pull request.

// mensajes.js — el plural y el orden los decide el idioma.
const catalogos = {
  es: { one: '# elemento', other: '# elementos' },
  en: { one: '# item', other: '# items' },
  pl: {
    one: '# element',
    few: '# elementy',
    many: '# elementów',
    other: '# elementu',
  },
};

export function contar(n, locale) {
  // Intl.PluralRules elige la categoría; el texto
  // lo pone el catálogo de cada idioma.
  const categoria =
    new Intl.PluralRules(locale).select(n);
  const catalogo = catalogos[locale] || catalogos.en;
  const plantilla = catalogo[categoria] || catalogo.other;
  const numero = new Intl.NumberFormat(locale).format(n);
  return plantilla.replace('#', numero);
}

contar(1, 'es');  // "1 elemento"
contar(3, 'es');  // "3 elementos"
contar(3, 'pl');  // "3 elementy"
contar(5, 'pl');  // "5 elementów"

// La fecha se guarda en UTC y se muestra en la zona
// de quien la lee.
export function cita(instante, locale, zona) {
  return new Intl.DateTimeFormat(locale, {
    dateStyle: 'long',
    timeStyle: 'short',
    timeZone: zona, // sin esto manda el navegador
  }).format(new Date(instante));
}

cita('2024-03-10T15:00:00Z', 'es-MX',
  'America/Mexico_City');
// "10 de marzo de 2024 a las 9:00 a.m."
cita('2024-03-10T15:00:00Z', 'de-DE',
  'Europe/Berlin');
// "10. März 2024 um 16:00"

En ICU el mismo mensaje se escribe {n, plural, one {# elemento} other {# elementos}} y lo compila FormatJS; arriba está la pieza que importa, la categoría.

04Cómo diagnosticarlo

HerramientaQué se miraCómo se ve el síntoma
axe DevTools / Lighthouse Nombre accesible, contraste, etiquetas. “Button has no accessible name” en los iconos.
Solo teclado Tab, Shift+Tab, Enter, Espacio, Escape. El foco desaparece, salta al pie o queda atrapado.
Lector de pantalla Nombre, rol, estado y anuncios tras una acción. Dice “botón” y nada más; el error no se anuncia.
Medidor de contraste El ratio real, también en hover y foco. El texto da 3.9:1 y el borde 2.2:1: se ve, no pasa.
lang y dir El idioma y la dirección del documento. Página en español con lang="en": el lector pronuncia mal.
Locale y zona forzados TZ, locale y timezoneId. La cita de las 09:00 se ve a las 15:00: el runner corre en UTC.
Zoom 200% / 320 px Reflujo y scroll horizontal. El botón parte la palabra o aparece scroll horizontal.

Ninguna herramienta ve la experiencia completa. La auditoría automática atrapa entre un tercio y la mitad; el resto aparece al intentar terminar una tarea con teclado o con lector.

Para decirlo en la entrevista

“Keyboard and focus are part of the component API: our dialog traps focus and returns it to the trigger.”

“Every string goes through ICU messages, so word order and plurals are the translator's call, not a concatenation.”

“We format dates with an explicit time zone and run tests with locale and TZ forced, so the runner doesn't decide.”

Comprueba que lo tienes

Cuatro preguntas. Al comprobar se marca la lección como completada.

Abres un diálogo con el botón “Editar dirección”. ¿Qué debe pasar con el foco?

Un botón visible dice “Pagar” y lleva aria-label="Enviar". ¿Cuál es el problema?

¿Por qué "Quedan " + n + " días" falla al traducir?

Guardas 2024-03-10T15:00:00Z y en Ciudad de México se ve “09:00”. ¿Qué falta para que en Berlín se vea “16:00”?