Skip to content
This page has been auto-translated and may contain errors.View in English

React Accesible

Toda aplicación React se renderiza al mismo HTML que el navegador siempre ha incluido, y cada herramienta de asistencia trabaja a partir del DOM que tus componentes producen. La accesibilidad en React es principalmente una serie de decisiones pequeñas sobre ese DOM: qué elemento renderizas, cómo obtiene su nombre, y qué haces cuando la pantalla cambia para alguien que no puede verlo cambiar.

Un detalle de JSX antes de lo demás. React renombra class a className y for a htmlFor, pero los atributos ARIA mantienen sus guiones: aria-live, aria-label, y un role común.

Los elementos semánticos van primero

Un <button> llega con un montón de comportamiento ya incorporado. Se sitúa en el orden de tabulación, así que el teclado puede alcanzarlo. Activa su controlador de clic en Enter y en Espacio. Un lector de pantalla lo anuncia como un botón y lee su texto como el nombre, que también es el nombre que el software de control de voz busca. El navegador se encarga del estado deshabilitado, del anillo de foco y del estilo activo.

jsx
// el navegador te da foco, activación por teclado y el anuncio "button"
<button className="die" onClick={hold}>{value}</button>

Un <div> con un controlador onClick obtiene un elemento de esa lista: el clic. Tab lo salta, Enter y Espacio no hacen nada, y un lector de pantalla lo lee como un conjunto de texto sin pista alguna de que algo sucederá si interactúas con él.

El parche habitual es role="button" más tabIndex={0}, que coloca el elemento en el orden de tabulación y cambia lo que se anuncia. El comportamiento sigue faltando. Tendrías que agregar un controlador onKeyDown, verificar Enter y Espacio, llamar a preventDefault() en Espacio para que la página deje de desplazarse, y mantener un estado deshabilitado hecho a mano sincronizado con el estilo. Eso es una cantidad considerable de código para reconstruir algo que el navegador ya proporciona. Recurrir al verdadero <button> es el camino más corto, y se mantiene correcto a medida que los navegadores cambian.

La misma lógica se extiende al resto del marcado: <a href> para navegación, <nav> y <main> como puntos de referencia por los que un lector de pantalla puede saltar, encabezados en orden para el esquema por el que la gente navega. La mayoría del trabajo de accesibilidad en una base de código React consiste en elegir el elemento que ya hace el trabajo.

Anunciando qué cambió

Una aplicación de una sola página se actualiza en el lugar. No hay carga de página que le diga a un lector de pantalla que algo sucedió, así que un cambio renderizado a mitad de la pantalla puede ser completamente silencioso. Una región activa le pasa esa información: un contenedor que el lector de pantalla observa y anuncia cada vez que su contenido cambia. La clase sr-only a continuación la oculta visualmente, usando un patrón CSS que se cubre más adelante en este capítulo.

jsx
<div aria-live="polite" className="sr-only">
  {isGameWon && <p>¡Ganaste! Presiona Nuevo Juego para empezar de nuevo.</p>}
</div>

El contenedor se renderiza cada vez, vacío al principio, y React intercambia un párrafo en él cuando isGameWon cambia. Ese orden es la parte que la gente se equivoca. El elemento que lleva aria-live tiene que estar en el DOM antes de que llegue el contenido, porque los lectores de pantalla registran regiones activas cuando las encuentran y luego observan mutaciones. Montar la región y su texto juntos en un único renderizado y muchos lectores de pantalla no anuncian nada: todo parece contenido nuevo ordinario. Mantener una región vacía en el árbol no cuesta nada y hace que el anuncio sea confiable.

aria-live="polite" coloca el anuncio en una cola. El lector de pantalla termina lo que está leyendo actualmente, luego entrega tu mensaje en la siguiente pausa natural, que puede ocurrir un instante después del cambio visual. Ese retraso es deliberado, y polite es la configuración correcta para casi todo.

Interacción con el teclado

Tab avanza a través de elementos enfocables, Shift+Tab retrocede, Enter activa enlaces y botones, y Espacio activa botones y alterna casillas de verificación.

El orden de tabulación sigue el orden del DOM, así que la secuencia que tu JSX renderiza es la secuencia por la que la gente se mueve. Reordenar visualmente con CSS deja un orden de tabulación que salta alrededor de la pantalla, y los valores positivos de tabIndex causan la misma confusión a propósito. tabIndex={-1} es el útil: hace que un elemento sea enfocable desde JavaScript mientras lo mantiene fuera de la secuencia de tabulación, que es lo que un objetivo de foco como un encabezado de diálogo necesita.

Dos reglas más. Mantén el foco visible: evita outline: none a menos que un estilo :focus-visible propio lo reemplace. Y mantén una salida disponible: un modal que deliberadamente mantiene el foco dentro de sí mismo necesita Escape para cerrar y necesita devolver el foco a su disparador.

Moviendo el foco deliberadamente

Cuando la UI cambia de forma, el foco puede acabar en ningún lugar. Alguien activa un botón, el botón se elimina o reemplaza, y el foco vuelve a <body>. El siguiente Tab comienza en la parte superior de la página, y el lector ha perdido su lugar.

La solución es mover el foco a un lugar sensato, que es uno de los usos legítimos de un ref:

jsx
function NewGameButton({ isGameWon, onNewGame }) {
  const buttonRef = useRef(null)

  useEffect(() => {
    if (isGameWon) {
      buttonRef.current.focus()
    }
  }, [isGameWon])

  return <button ref={buttonRef} onClick={onNewGame}>Nuevo Juego</button>
}

El effect se ejecuta después de que React haya confirmado ese nodo en la pantalla, así que el elemento está allí para recibir foco. Proteger con isGameWon lo evita robar foco en cada renderizado.

El mismo patrón cubre los otros momentos comunes: un diálogo toma el foco al abrirse y lo devuelve al disparador al cerrarse, una falla de validación envía el foco al primer campo inválido, eliminar una fila mueve el foco a la fila que la reemplazó. La regla subyacente es una línea: si tu código eliminó lo que tenía el foco, tu código decide a dónde va el foco después.

Texto visualmente oculto

Hay muchísimo estado que es obvio del layout y silencioso para un lector de pantalla: una marca de verificación verde junto a un campo, un dado que se ve presionado, un número que se lee claramente de donde está. El texto visualmente oculto explica eso para cualquiera que esté escuchando la página.

La convención es una clase llamada sr-only. No tiene significado para React ni para el navegador: es un nombre de clase simple, y estas reglas CSS son las que hacen el trabajo.

css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

El elemento se mantiene en el árbol de accesibilidad mientras no ocupa espacio visual. display: none y visibility: hidden lo eliminarían del árbol también, ocultándolo de todos.

Un botón solo con ícono es el caso cotidiano. O dale un aria-label, o pon texto real dentro y ocúltalo visualmente:

jsx
<button onClick={onClose}>
  <XIcon aria-hidden="true" />
  <span className="sr-only">Cerrar</span>
</button>

aria-hidden="true" mantiene el SVG decorativo fuera del anuncio, y el span oculto proporciona el nombre. Una advertencia sobre aria-label: establece un nombre accesible en elementos interactivos y en cualquier cosa que lleve un rol explícito, y los navegadores frecuentemente lo ignoran en un <div> o <span> simple sin rol. Mantenlo en botones, enlaces, entradas y puntos de referencia etiquetados.

Leer el código fuente solo te lleva hasta cierto punto con cualquiera de esto. Activa VoiceOver con Cmd+F5 y escucha tu propia aplicación, y ejecuta axe DevTools en el navegador para detectar automáticamente etiquetas faltantes y controles sin nombre.

Cada control de formulario necesita una etiqueta, y los formularios son donde la brecha se muestra más a menudo. Un <label> vinculado a una entrada le da al campo su nombre accesible, así que un lector de pantalla lee "Correo electrónico, editar texto" cuando el foco llega allí, y el texto de la etiqueta se convierte en un objetivo de clic para el campo.

Dos conexiones funcionan. Apunta la etiqueta a la entrada por id, usando htmlFor de React para el atributo HTML for:

jsx
<label htmlFor="email">Correo electrónico</label>
<input id="email" type="email" name="email" />

O envuelve la entrada en la etiqueta y salta el id completamente:

jsx
<label>
  Correo electrónico
  <input type="email" name="email" />
</label>

Envolver es apropiado para una casilla de verificación o un botón de radio, donde el texto ya está junto al control. La versión htmlFor te da más libertad sobre el layout.

Un id codificado como email funciona para un formulario en una página. Sube ese marcado a un <TextField> reutilizable y dos instancias en la misma página emiten el mismo id, así que htmlFor se vincula a cuál se renderizó primero y la etiqueta silenciosamente deja de funcionar para cada campo después. useId genera un id que es único por instancia de componente, que es el trabajo para el que React lo agregó:

jsx
function TextField({ label, ...props }) {
  const id = useId()

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} {...props} />
    </>
  )
}

Sufija ese valor para ids relacionados, ${id}-hint para un elemento de descripción, así que una llamada cubre el control completo.

El texto de placeholder hace un trabajo diferente. Un placeholder desaparece en el instante en que alguien escribe un carácter, así que cuando lleva la única descripción del campo, esa descripción desaparece en el momento en que se necesita para verificar la respuesta. El estilo de placeholder predeterminado es gris claro, que típicamente falla en los requisitos de contraste, y el soporte del lector de pantalla para el atributo es inconsistente. Úsalo para un ejemplo del formato esperado, juan@ejemplo.com bajo una etiqueta que dice "Correo electrónico".

El texto de ayuda adicional y los mensajes de error se adjuntan con aria-describedby, que apunta al id del elemento que sostiene el texto:

jsx
<label htmlFor="password">Contraseña</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
  aria-invalid={error ? true : undefined}
/>
<p id="password-hint">{error || 'Al menos 12 caracteres.'}</p>

La descripción se lee después de la etiqueta y el tipo de campo, así que llega como contexto en lugar de como el nombre. aria-invalid marca el campo como que falla la validación, e intercambiar el texto de error en el elemento al que aria-describedby ya apunta mantiene el anuncio en un nodo que el lector de pantalla está rastreando. Un nivel arriba, un conjunto de botones de radio pertenece dentro de un <fieldset> con una <legend> que lleva la pregunta.

aria-live toma tres valores, y la elección decide si la región ayuda o daña. off es el predeterminado, lo que significa que los cambios no se anuncian. polite pone el anuncio en cola y lo entrega cuando el lector de pantalla alcanza una pausa en lo que ya está diciendo. assertive interrumpe, cortando el anuncio actual para entregar el tuyo. Assertive es casi siempre la opción equivocada: resérvalo para algo que genuinamente bloquee el progreso de la persona, como una sesión que expira en diez segundos. Una confirmación de guardado, un recuento de resultados de búsqueda, un cambio de estado del juego todos pertenecen en una región polite.

Dos roles llevan una educación implícita y tienden a ser anunciados de forma más consistente que un atributo aria-live desnudo: role="status" se comporta como polite, role="alert" como assertive, y role="status" más aria-live="polite" es un sólido predeterminado para una región de estado. aria-atomic="true" entonces lee todo el contenido de la región en cualquier cambio, que se adapta a una oración corta que solo tiene sentido completa; el predeterminado lee solo lo que cambió, que se adapta a un registro donde cada línea se sostiene sola.

El modo de falla que vale la pena nombrar es la región que anuncia demasiado. Conecta una a un valor que se actualiza en cada pulsación de tecla, digamos un recuento de resultados bajo una caja de búsqueda, y cada carácter pone en cola otro anuncio. La entrega polite se agrega a la cola en lugar de reemplazarla, así que la persona escucha un flujo de números obsoletos sobre el campo que todavía está escribiendo, y el eco de su propio mecanografiado queda enterrado. Una bandera de carga parpadeante o tres regiones en competencia causan el mismo apilamiento.

Así que mantén pocas regiones activas, debounce cualquier cosa impulsada por mecanografía hasta que el valor se estabilice, y anuncia solo los momentos que harían que un usuario vidente levante la vista. Una aplicación que no dice nada es al menos explorable: la persona puede navegarla con los propios comandos de su lector de pantalla a su propio ritmo. Una aplicación que habla constantemente es una que abandonan.

JunoEl elemento correcto hace la mayoría del trabajo Recurre a un verdadero button cuando algo es clickable, y una verdadera label junto a cada entrada. Esos elementos vienen con soporte de teclado y un nombre que un lector de pantalla puede leer, todo gratis.

Cuando algo cambia en la pantalla que una persona escuchando la página de otro modo extrañaría, pon una oración corta dentro de un div con aria-live="polite", y mantén ese div en la página desde el principio para que el cambio sea notado.

JunoEl elemento correcto hace la mayoría del trabajo Los elementos semánticos te dan foco, activación por teclado y anuncios sin código, por eso parchear un div con role y tabIndex te deja escribiendo tu propio manejo de teclas.

Etiqueta cada control con htmlFor o una label envolvente, y trata un placeholder como una pista de formato, ya que desaparece en el momento en que alguien escribe.

Mantén una región aria-live="polite" montada e intercambia su texto, y mueve el foco con un ref cada vez que tu código elimina lo que lo tenía.

JunoEl elemento correcto hace la mayoría del trabajo Las regiones activas se registran cuando el lector de pantalla las encuentra, así que la región debe estar en el DOM antes de que el contenido cambie, y polite entrega en la siguiente pausa del habla mientras assertive interrumpe y es casi siempre la opción incorrecta.

role="status" y role="alert" llevan la misma educación con mejor consistencia, y aria-atomic decide si toda la región o solo el delta se lee.

Una región demasiado ansiosa impulsada por pulsaciones de tecla pone en cola anuncios más rápido de lo que se pueden hablar, lo cual es peor para el usuario que el silencio.

Lo siguiente: Más allá de los fundamentos, un mapa de qué viene después de los fundamentos.