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 siempre ha enviado el navegador, y cada herramienta de asistencia funciona a partir del DOM que producen tus componentes. La accesibilidad en React es principalmente una serie de pequeñas decisiones 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 conservan sus guiones: aria-live, aria-label, y un simple role.

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. Dispara su manejador de clic en Enter y en Espacio. Un lector de pantalla lo anuncia como botón y lee su texto como el nombre, que también es el nombre que identifica el software de control de voz. El navegador maneja el estado deshabilitado, el anillo de enfoque y el estilo activo.

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

Un <div> con un manejador 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 una serie de texto sin indicación de que algo sucederá si interactúas con él.

El parche usual es role="button" más tabIndex={0}, que pone el elemento en el orden de tabulación y cambia lo que se anuncia. El comportamiento sigue faltando. Tendrías que agregar un manejador onKeyDown, verificar Enter y Espacio, llamar a preventDefault() en Espacio para que la página deje de desplazarse, y luego mantener un estado deshabilitado hecho a mano sincronizado con el estilo. Es bastante código para reconstruir algo que el navegador ya envía. Usar el verdadero <button> es el camino más corto, y se mantiene correcto conforme 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 entre 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 es elegir el elemento que ya hace el trabajo.

Anunciando lo que 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 transmite esa información: un contenedor que el lector de pantalla observa y anuncia siempre que su contenido cambia. La clase sr-only a continuación lo oculta visualmente, usando un patrón CSS que se cubrirá más adelante en este capítulo.

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

El envoltorio se renderiza cada vez, vacío al principio, y React intercambia un párrafo 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. Monta la región y su texto juntos en un solo renderizado y muchos lectores de pantalla no anuncian nada en absoluto: 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" pone 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 llegar un momento después del cambio visual. Ese retraso es deliberado, y polite es la configuración correcta para casi todo.

Interacción del 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 renderiza tu JSX es la secuencia por la que la gente se mueve. Reordenar visualmente con CSS deja un orden de tabulación que salta por toda la pantalla, y los valores tabIndex positivos 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 necesita un destino de enfoque como un encabezado de diálogo.

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

Moviendo el enfoque deliberadamente

Cuando la interfaz cambia de forma, el enfoque puede terminar en ningún lado. Alguien activa un botón, el botón se elimina o reemplaza, y el enfoque 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 enfoque a algún 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 efecto se ejecuta después de que React ha comprometido ese nodo en la pantalla, así que el elemento está ahí para recibir el enfoque. Proteger con isGameWon lo evita robar enfoque en cada renderizado.

El mismo patrón cubre los otros momentos comunes: una modal toma enfoque al abrirse y lo devuelve al disparador al cerrar, una validación fallida envía el enfoque al primer campo inválido, eliminar una fila mueve el enfoque a la fila que la reemplazó. La regla debajo es una línea: si tu código eliminó la cosa que tenía enfoque, tu código decide dónde va el enfoque después.

Texto visualmente oculto

Mucho estado es obvio desde el diseño y silencioso para un lector de pantalla: una marca verde junto a un campo, un dado que se ve presionado, un número que se lee claramente desde donde está. El texto visualmente oculto deletrea eso para cualquiera que esté escuchando la página.

La convención es una clase llamada sr-only. No tiene significado para React o para el navegador: es un nombre de clase simple, y estas reglas CSS son lo que hace 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 permanece en el árbol de accesibilidad mientras no ocupa espacio visual. display: none y visibility: hidden lo quitarían de ese árbol también, ocultándolo de todos.

El botón solo con icono 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 simple <div> o <span> sin rol. Mantenlo en botones, enlaces, entradas y puntos de referencia etiquetados.

Leer el código solo te llevará 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 atrapar etiquetas faltantes y controles sin nombre automáticamente.

Cada control de formulario necesita una etiqueta, y los formularios son donde la brecha se muestra más a menudo. Una <label> atada a una entrada le da al campo su nombre accesible, así que un lector de pantalla lee "Dirección de correo electrónico, texto editable" cuando el enfoque llega allí, y el texto de la etiqueta se convierte en un destino de clic para el campo.

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

jsx
<label htmlFor="email">Dirección de correo electrónico</label>
<input id="email" type="email" name="email" />

O envuelve la entrada en la etiqueta y salta el id por completo:

jsx
<label>
  Dirección de correo electrónico
  <input type="email" name="email" />
</label>

Envolver se adapta bien a 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 diseño.

Un id codificado como email se sostiene para un formulario en una página. Levanta ese marcado en un <TextField> reutilizable y dos instancias en la misma página emiten el mismo id, así que htmlFor se vincula a cualquiera que se haya renderizado 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 todo el control.

El texto de marcador de posición hace un trabajo diferente. Un marcador de posición 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 verificar la respuesta. El estilo de marcador de posición predeterminado es gris claro, que típicamente falla en los requisitos de contraste, y el soporte de lector de pantalla para el atributo es inconsistente. Úsalo para un ejemplo del formato esperado, [email protected] bajo una etiqueta que diga "Dirección de 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 contiene 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 fallido en validación, e intercambiar el texto de error en el elemento que aria-describedby ya señala 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 contiene 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 incorrecta: 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 a una región polite.

Dos roles llevan amabilidad implícita y tienden a anunciarse más consistentemente que un atributo aria-live simple: 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" luego lee todo el contenido de la región en cualquier cambio, lo que se adapta a una oración corta que solo tiene sentido entera; el predeterminado lee solo lo que cambió, lo que se adapta a un registro donde cada línea se mantiene por su cuenta.

El modo de fallo 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 un cuadro de búsqueda, y cada carácter pone en cola otro anuncio. La entrega polite agrega a la cola en lugar de reemplazarla, así que la persona escucha un flujo de números obsoletos sobre el campo en el que todavía está escribiendo, y su propio eco de escritura se entierra. Una bandera de carga parpadeante o tres regiones compitiendo causan el mismo amontonamiento.

Así que mantén las regiones activas pocas, debounce cualquier cosa impulsada por escribir hasta que el valor se estabilice, y anuncia solo los momentos que harían que un usuario vidente mire hacia arriba. 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 parte del trabajo Usa un verdadero button cuando algo es clickeable, 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 otra manera perderí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 se note.
JunoEl elemento correcto hace la mayor parte del trabajo Los elementos semánticos te dan enfoque, activación por teclado y anuncios sin código, razón por la cual 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 marcador de posición 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 enfoque con un ref siempre que tu código elimine la cosa que lo tenía.
JunoEl elemento correcto hace la mayor parte 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 en el habla mientras assertive interrumpe y casi siempre es la opción incorrecta. role="status" y role="alert" llevan la misma amabilidad con mejor consistencia, y aria-atomic decide si la región entera o solo el delta se lee. Una región demasiado entusiasta impulsada por pulsaciones de teclas pone en cola anuncios más rápido de lo que pueden hablarse, lo cual es peor para el usuario que el silencio.

Lo siguiente: Más allá de lo básico, un mapa de lo que viene después de los fundamentos.