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.
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.
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.
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 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.
| Herramienta | Qué se mira | Có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.
“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?
Por qué: si el foco se queda atrás, el primer Tab recorre la página de fondo y la persona no sabe que hay un diálogo. Al cerrar, devolverlo al disparador evita reiniciar el recorrido desde el principio.
Un botón visible dice “Pagar” y lleva aria-label="Enviar". ¿Cuál es el problema?
Por qué: quien usa control por voz dice lo que ve: “clic en Pagar”. Si el nombre accesible es otro, el comando no encuentra el botón. La regla es que el nombre contenga el texto visible, no que lo reemplace.
¿Por qué "Quedan " + n + " días" falla al traducir?
Por qué: cada idioma coloca el número donde quiere y tiene sus propias categorías de plural: el polaco separa pocos de muchos y el árabe tiene cero y dos. La cadena completa, con su hueco, es lo que se traduce.
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”?
Por qué: el instante UTC ya es correcto; lo que falta es decir en qué zona se dibuja. Una resta fija de horas funciona hasta el cambio de horario, y guardar la hora local pierde el instante cuando la persona viaja o cuando hay dos horarios de verano distintos.