A lo largo de este módulo hemos ido dejando avisos: que poner onClick en un <article> funciona con el ratón pero no con el teclado, que el color no puede ser el único portador de información, que un mensaje de error rojo debajo de un campo no basta para que un lector de pantalla lo relacione con él. Ha llegado el momento de saldar todas esas deudas a la vez. La accesibilidad no es una capa que se añade al final ni una lista de atributos exóticos: es, en un 90 %, escribir el HTML correcto y no romper lo que el navegador ya te da hecho. En esta lección revisarás todo lo construido en el módulo —los manejadores de eventos, las listas, el formulario de reserva— y lo dejarás usable con teclado y con lector de pantalla, aprendiendo por el camino qué atributos ARIA existen, cuándo usarlos y, más importante, cuándo no usarlos.

Contenido

  1. Por qué importa la accesibilidad
  2. Las cuatro ideas de las WCAG
  3. HTML semántico primero
  4. El caso de la tarjeta pulsable
  5. Etiquetas y mensajes de error en formularios
  6. Navegación por teclado y gestión del foco
  7. Regiones activas: anunciar lo que cambia
  8. Imágenes, iconos y el problema del color
  9. Atributos ARIA de uso común
  10. Cómo comprobar que funciona

  1. Por qué importa la accesibilidad

Hay tres razones, y las tres son buenas.

Personas. Alrededor del 15 % de la población mundial vive con alguna discapacidad. No hablamos solo de ceguera total: hay baja visión, daltonismo, discapacidad motriz que impide usar un ratón con precisión, discapacidad cognitiva, sordera. Y hay discapacidades temporales (un brazo escayolado) y situacionales (usar el móvil a pleno sol, con una mano y con prisa). En CicloUrbano, alguien puede estar consultando el catálogo con una mano mientras sujeta la bici con la otra.

Obligación legal. En la Unión Europea, la Directiva 2016/2102 obliga a que los sitios y aplicaciones del sector público sean accesibles, y el Acta Europea de Accesibilidad extiende requisitos similares a numerosos servicios privados, incluido el comercio electrónico. En España, el Real Decreto 1112/2018 desarrolla esas obligaciones. En Estados Unidos, la ADA se aplica de forma consolidada a los sitios web. Traducido: para muchos proyectos, no es opcional.

Calidad general. Una interfaz accesible es mejor para todo el mundo. Los subtítulos sirven en un vagón ruidoso; un buen contraste ayuda al sol; los atajos de teclado los agradece quien introduce cien reservas al día; y el marcado semántico mejora el SEO y facilita las pruebas automatizadas, como comprobarás en el Módulo 9.

Y hay un argumento práctico decisivo: arreglar la accesibilidad al final cuesta cinco veces más que hacerlo bien desde el principio. Cambiar un <div onClick> por un <button> mientras escribes el componente cuesta cero; hacerlo seis meses después implica rehacer estilos, revisar la maquetación y volver a probarlo todo.

  1. Las cuatro ideas de las WCAG

Las WCAG (Web Content Accessibility Guidelines) son el estándar internacional. Detrás de sus decenas de criterios hay solo cuatro principios, que en inglés forman el acrónimo POUR:

Principio En una frase Ejemplo en CicloUrbano
Perceptible La información debe poder percibirse por más de un sentido o canal El estado de la bicicleta se dice con texto, no solo con color
Operable Todo lo que se puede hacer con ratón debe poder hacerse con teclado Se puede recorrer el catálogo y reservar sin tocar el ratón
Comprensible La interfaz debe ser predecible y los mensajes, claros «La reserva mínima es de 1 hora», no «Valor inválido»
Robusto El marcado debe ser válido e interpretable por cualquier tecnología de asistencia HTML semántico correcto en lugar de <div> con comportamiento simulado

Las WCAG definen además tres niveles de conformidad: A (mínimo), AA (el que exigen casi todas las normativas) y AAA (muy exigente, poco frecuente). El objetivo razonable de cualquier proyecto es el nivel AA.

  1. HTML semántico primero

La primera regla de la accesibilidad —y la que más problemas evita— es esta: usa el elemento HTML que corresponde a lo que estás haciendo.

Un <button> no es un <div> con estilos de botón. Un <button> trae de fábrica:

Lo que da un <button> Lo que hay que reimplementar en un <div>
Es enfocable con Tab tabIndex={0}
Se activa con Enter y Espacio Un manejador onKeyDown que compruebe ambas teclas
Se anuncia como «botón» al lector de pantalla role="button"
Tiene un anillo de foco visible por defecto Estilos :focus-visible propios
Admite disabled, con su semántica completa aria-disabled y bloquear el manejador a mano
Envía formularios con type="submit" Nada equivalente

Cinco cosas que hay que reconstruir, y basta olvidar una para dejar a alguien fuera.

Elemento correcto Cuándo usarlo Error habitual
<button> Ejecuta una acción en la página <div onClick>
<a href> Navega a otra dirección <button> con navegación por código
<nav> Bloque de navegación <div class="menu">
<main> Contenido principal, uno por página <div id="contenido">
<ul> / <ol> / <li> Listas de elementos <div> repetidos
<table> con <th> Datos tabulares Rejilla de <div>
<form> Conjunto de campos que se envía <div> con un botón
<fieldset> + <legend> Grupo de campos relacionados (radios) Campos sueltos
<h1><h6> en orden Estructura del documento Encabezados elegidos por tamaño

Sobre los encabezados hay un matiz que se pasa por alto: su nivel comunica jerarquía, no tamaño. Quien navega con lector de pantalla salta de encabezado en encabezado para hacerse un mapa de la página; si eliges un <h4> porque «se ve más pequeño», ese mapa queda roto. El tamaño se decide en el CSS.

La estructura de CicloUrbano, con la semántica en su sitio:

// src/App.jsx (estructura)
function App() {
  return (
    <>
      <Cabecera />        {/* <header> con <nav> dentro */}
      <main>              {/* el contenido principal, uno solo por página */}
        <ResumenFlota flota={bicicletas} />       {/* <section> con <h2> */}
        <SelectorTipo />                          {/* botones reales */}
        <ListaBicicletas bicicletas={bicicletas} /> {/* <ul> de tarjetas */}
        <FormularioReserva bicicletas={bicicletas} /> {/* <form> */}
      </main>
      <PieDePagina />     {/* <footer> */}
    </>
  );
}

Esos elementos crean regiones que las tecnologías de asistencia permiten saltar directamente: «ir al contenido principal», «ir a la navegación». Con <div> no existe esa posibilidad.

  1. El caso de la tarjeta pulsable

En la lección 03-01 pusimos onClick en el <article> de TarjetaBicicleta y dejamos anotada la deuda. Vamos a saldarla.

El problema, en concreto: con un <article onClick>, quien navega con teclado nunca llega a la tarjeta —no es enfocable— y quien usa lector de pantalla oye «artículo», sin ninguna pista de que se pueda pulsar.

Solución 1: un botón dentro, y la tarjeta no es pulsable (la mejor)

<article className={estilos.tarjeta}>
  <h3 className={estilos.titulo}>
    <button type="button" className={estilos.enlaceTitulo} onClick={manejarClicTarjeta}>
      {bicicleta.modelo}
    </button>{' '}
    <EtiquetaEstado estado={bicicleta.estado} />
  </h3>
  …
</article>

Solo el título es pulsable, y es un <button> de verdad. Con estilos se puede hacer que parezca un enlace o un título normal. Esta es la opción recomendada: cero atributos ARIA, cero manejadores de teclado, todo funciona.

Solución 2: la tarjeta entera pulsable, hecha bien

Si el diseño exige que toda la tarjeta sea un área pulsable, hay que reconstruir a mano lo que un botón da gratis:

function TarjetaBicicleta({ bicicleta, alSeleccionar, alReservar, nombreEstacion }) {
  function manejarClicTarjeta() {
    if (alSeleccionar) {
      alSeleccionar(bicicleta);
    }
  }

  function manejarTecla(evento) {
    // Reproducimos lo que un <button> hace de serie
    if (evento.key === 'Enter' || evento.key === ' ') {
      evento.preventDefault();   // el Espacio, si no, hace scroll
      manejarClicTarjeta();
    }
  }

  return (
    <article
      className={estilos.tarjeta}
      role="button"                    // se anunciará como botón
      tabIndex={0}                     // entra en el orden de tabulación
      onClick={manejarClicTarjeta}
      onKeyDown={manejarTecla}         // Enter y Espacio
      aria-label={`Ver detalle de ${bicicleta.modelo}, ${bicicleta.estado}`}
    >
      …
    </article>
  );
}
/* TarjetaBicicleta.module.css — el foco DEBE verse */
.tarjeta:focus-visible {
  outline: 3px solid var(--color-marca);
  outline-offset: 2px;
}

Cuatro añadidos para igualar a un <button>, y aún queda un problema: si dentro de la tarjeta hay otro botón («Reservar»), acabas con un control dentro de otro control, algo que ninguna tecnología de asistencia interpreta bien. Por eso la solución 1 es preferible casi siempre.

Enfoque Código extra Riesgos
Botón dentro de la tarjeta Ninguno Ninguno
Tarjeta entera con role="button" role, tabIndex, onKeyDown, aria-label, estilos de foco Controles anidados, teclas olvidadas

La regla: si algo se puede pulsar, que sea un <button> o un <a>. Reconstruir el comportamiento solo se justifica cuando no hay alternativa.

  1. Etiquetas y mensajes de error en formularios

Aquí cerramos lo prometido en la lección anterior. Un formulario accesible necesita tres cosas: etiquetas asociadas, textos de ayuda vinculados y errores anunciados.

<label htmlFor> asociado al id

{/* ✔ CORRECTO: htmlFor apunta al id del campo */}
<label htmlFor="horas">Duración (horas)</label>
<input id="horas" name="horas" type="number" />

{/* ✔ TAMBIÉN CORRECTO: el campo va dentro de la etiqueta */}
<label>
  Duración (horas)
  <input name="horas" type="number" />
</label>

{/* ✘ MAL: un párrafo no es una etiqueta */}
<p>Duración (horas)</p>
<input name="horas" type="number" />

Recuerda de la lección 01-04: en JSX se escribe htmlFor, no for, porque for es una palabra reservada de JavaScript.

Qué se gana con la asociación correcta:

  • Al pulsar el texto de la etiqueta, el campo recibe el foco. El área pulsable crece, lo que ayuda especialmente en móvil y a quien tiene poca precisión motriz.
  • El lector de pantalla anuncia «Duración (horas), cuadro de edición numérico» al llegar al campo. Sin etiqueta asociada, solo dice «cuadro de edición», y la persona no sabe qué escribir.

En listas generadas con map, cuidado con los id duplicados: deben ser únicos en toda la página. Genéralos a partir del dato: id={horas-${bicicleta.id}}.

aria-describedby para la ayuda

Cuando un campo necesita una explicación adicional, se vincula con aria-describedby, que acepta uno o varios id separados por espacios:

<label htmlFor="horas">Duración (horas)</label>
<input
  id="horas"
  name="horas"
  type="number"
  min={1}
  max={24}
  aria-describedby="horas-ayuda"
/>
<p id="horas-ayuda" className={estilos.ayuda}>
  Entre 1 y 24 horas. El precio se calcula por hora completa.
</p>

El lector de pantalla lee primero la etiqueta y después la descripción. La diferencia con aria-label es importante: la etiqueta dice qué es el campo; la descripción aporta detalles adicionales.

aria-invalid y el error anunciado

Y aquí está la pieza que cerraba la lección 03-05: un párrafo rojo debajo del campo es invisible para un lector de pantalla si no está asociado al campo y no se anuncia al aparecer.

<div className={estilos.campo}>
  <label htmlFor="horas">Duración (horas)</label>

  <input
    id="horas"
    name="horas"
    type="number"
    min={1}
    max={24}
    value={datos.horas}
    onChange={manejarCambio}
    onBlur={manejarBlur}
    aria-invalid={mostrarError('horas')}
    aria-describedby={
      mostrarError('horas') ? 'horas-ayuda horas-error' : 'horas-ayuda'
    }
    className={clases(estilos.control, mostrarError('horas') && estilos.invalido)}
  />

  <p id="horas-ayuda" className={estilos.ayuda}>
    Entre 1 y 24 horas.
  </p>

  {mostrarError('horas') && (
    <p id="horas-error" className={estilos.error} role="alert">
      <span aria-hidden="true">⚠ </span>
      {errores.horas}
    </p>
  )}
</div>

Los cuatro mecanismos, uno a uno:

Mecanismo Qué hace
aria-invalid={true} El lector de pantalla anuncia el campo como «no válido» al llegar a él
aria-describedby="… horas-error" Asocia el mensaje al campo: se lee al enfocarlo
role="alert" Convierte el párrafo en una alerta: se anuncia en cuanto aparece, sin esperar al foco
aria-hidden="true" en el icono El símbolo «⚠» no se lee en voz alta; su información ya está en el texto

Fíjate en que aria-describedby cambia según haya error o no, encadenando los dos id. Así el campo conserva su ayuda y suma el error cuando existe.

Y un detalle que ahora encaja: en la lección 03-05 recomendamos no deshabilitar el botón de envío. Uno de los motivos es de accesibilidad: los elementos con disabled no reciben foco, así que quien navega con teclado puede llegar al final del formulario, no encontrar el botón y no entender por qué. Es mejor un botón activo que, al pulsarlo, explique qué falta.

  1. Navegación por teclado y gestión del foco

Toda la funcionalidad debe poder usarse solo con el teclado. Estas son las teclas que la gente espera:

Tecla Comportamiento esperado
Tab Ir al siguiente elemento interactivo
Mayús + Tab Ir al anterior
Enter Activar botones y enlaces; enviar el formulario desde un campo de texto
Espacio Activar botones; marcar y desmarcar casillas
Flechas Moverse dentro de un grupo de radios, un select o un menú
Escape Cerrar un diálogo, un menú o cancelar

El orden de foco

El orden de tabulación es el orden del marcado, no el visual. Si con CSS colocas un elemento a la izquierda pero en el HTML está al final, el foco irá allí en último lugar y quien navegue con teclado se desorientará. La solución no es tocar tabIndex: es poner el marcado en el orden lógico y maquetar con order de flexbox o grid solo cuando no rompa el sentido.

tabIndex: tres valores y una prohibición

Valor Significado Cuándo usarlo
tabIndex={0} Enfocable con Tab, en el orden natural Un elemento no interactivo al que has dado comportamiento (con su role)
tabIndex={-1} No enfocable con Tab, pero sí por código con .focus() Destinos de foco programático: un diálogo que se abre, un mensaje de error al que quieres saltar
tabIndex={1} o mayor Se adelanta a todo el resto Nunca. Destroza el orden de la página y es imposible de mantener

Los elementos interactivos nativos —<button>, <a href>, <input>, <select>, <textarea>— ya son enfocables: no les pongas tabIndex.

El foco debe verse

El error más extendido y más dañino:

/* ✘ NUNCA hagas esto */
*:focus { outline: none; }

Eliminar el contorno de foco deja a quien navega con teclado literalmente a ciegas: no sabe dónde está. Si el contorno por defecto no encaja con tu diseño, sustitúyelo, no lo elimines:

/* ✔ Un foco visible y acorde con la marca */
.boton:focus-visible {
  outline: 3px solid var(--color-marca);
  outline-offset: 2px;
  border-radius: var(--radio);
}

:focus-visible es la pseudoclase moderna que muestra el indicador cuando el navegador estima que hace falta —navegación con teclado— y lo omite tras un clic de ratón. Es lo mejor de ambos mundos.

Manejar teclas: Enter y Escape

En la lección 03-01 ya añadiste Escape al SelectorTipo para volver al filtro «Todas». El patrón general:

function manejarTecla(evento) {
  if (evento.key === 'Escape') {
    cerrar();
    return;
  }

  if (evento.key === 'Enter') {
    confirmar();
  }
}

Se usa siempre evento.key con el nombre de la tecla ('Enter', 'Escape', 'ArrowRight', ' ' para el espacio). evento.keyCode está obsoleto desde hace años: no lo uses.

  1. Regiones activas: anunciar lo que cambia

Cuando algo cambia en la pantalla sin que la persona haya movido el foco —«Reserva creada», «3 bicicletas encontradas», «Guardando…»—, un lector de pantalla no dice nada, porque solo lee lo que está bajo el foco. La solución son las regiones activas (live regions).

import { useState } from 'react';

function App() {
  const [reservas, setReservas] = useState([]);
  const [aviso, setAviso] = useState('');

  function manejarCrearReserva(reserva) {
    setReservas((anteriores) => [...anteriores, reserva]);
    setAviso(`Reserva ${reserva.id} creada para ${reserva.horas} horas.`);
  }

  return (
    <main>
      <FormularioReserva bicicletas={bicicletas} alCrearReserva={manejarCrearReserva} />

      {/* La región existe SIEMPRE en el marcado, aunque esté vacía */}
      <p className="aviso-vivo" role="status" aria-live="polite">
        {aviso}
      </p>
    </main>
  );
}

El detalle que casi todo el mundo se salta: la región debe existir en el DOM desde el principio, aunque esté vacía. Si el elemento con aria-live aparece a la vez que su contenido, muchos lectores de pantalla no lo anuncian, porque no estaban observando ese nodo. Renderiza siempre el contenedor y cambia solo su texto.

Valor Comportamiento Para qué
aria-live="polite" Espera a que la persona termine lo que está haciendo Confirmaciones, recuentos de resultados, «guardado»
aria-live="assertive" Interrumpe de inmediato Errores graves, sesión a punto de caducar. Úsalo con moderación
role="status" Equivale a aria-live="polite" Atajo semántico habitual
role="alert" Equivale a aria-live="assertive" Mensajes de error, como los del apartado 5

En CicloUrbano hay tres sitios donde una región activa aporta de verdad:

  1. Confirmación de reserva creada, con role="status".
  2. Recuento de resultados del catálogo al cambiar el filtro: «Se han encontrado 3 bicicletas.»
  3. Mensajes de error del formulario, con role="alert".

  1. Imágenes, iconos y el problema del color

Texto alternativo

Todo <img> necesita un atributo alt. La pregunta clave es: si esta imagen no cargara, ¿qué texto transmitiría la misma información?

{/* Imagen informativa: alt describe el contenido relevante */}
<img src="/img/bici-urbana.webp" alt="Bicicleta Urbana Clásica aparcada en Plaza Mayor" />

{/* Imagen puramente decorativa: alt VACÍO, nunca ausente */}
<img src="/img/adorno.svg" alt="" />

{/* ✘ MAL: sin alt, el lector de pantalla leerá el nombre del fichero */}
<img src="/img/bici-urbana.webp" />

alt="" y la ausencia de alt no son lo mismo: el primero dice «esta imagen no aporta información, ignórala»; el segundo deja al lector de pantalla adivinando, y lo habitual es que acabe leyendo la URL en voz alta.

Y no empieces el texto con «Imagen de…»: el lector ya anuncia que es una imagen.

Iconos

Los iconos que son solo decoración deben ocultarse; los que son la información necesitan una alternativa textual:

{/* Icono decorativo junto a un texto que ya lo dice todo */}
<button type="button">
  <span aria-hidden="true">🔒</span> Bloquear bicicleta
</button>

{/* Botón SOLO con icono: necesita nombre accesible */}
<button type="button" aria-label="Bloquear bicicleta">
  <span aria-hidden="true">🔒</span>
</button>

Sin el aria-label, el segundo botón se anuncia como «botón», sin más. Un formulario lleno de botones así es inutilizable.

No transmitir información solo con color

Este es el criterio 1.4.1 de las WCAG, y afecta directamente a EtiquetaEstado. Aproximadamente el 8 % de los hombres tiene alguna forma de daltonismo; para muchos de ellos, el verde de «disponible» y el rojo de «mantenimiento» son prácticamente el mismo tono.

La buena noticia es que nuestro componente ya lo hacía bien desde la lección 03-02: muestra el texto del estado además del color. Lo dejamos redondo:

// src/componentes/EtiquetaEstado.jsx
import { clases } from '../utilidades/clases.js';
import estilos from './EtiquetaEstado.module.css';

const ESTADOS = {
  disponible: { texto: 'Disponible', simbolo: '●', clave: 'disponible' },
  alquilada: { texto: 'Alquilada', simbolo: '◐', clave: 'alquilada' },
  mantenimiento: { texto: 'En taller', simbolo: '✕', clave: 'mantenimiento' }
};

const DESCONOCIDO = { texto: 'Estado desconocido', simbolo: '?', clave: 'desconocido' };

/**
 * Distintivo del estado de una bicicleta.
 * La información se transmite por TRES canales: color, símbolo y texto.
 * Props:
 *  - estado (cadena, opcional, por defecto 'disponible')
 */
function EtiquetaEstado({ estado = 'disponible' }) {
  const datos = ESTADOS[estado] ?? DESCONOCIDO;

  return (
    <span className={clases(estilos.etiqueta, estilos[datos.clave])}>
      {/* El símbolo es redundante para quien ve el texto: se oculta al lector */}
      <span aria-hidden="true">{datos.simbolo} </span>
      {datos.texto}
    </span>
  );
}

export default EtiquetaEstado;

Tres canales independientes: color para quien lo distingue, símbolo para quien no, y texto para todo el mundo, incluido el lector de pantalla.

Contraste

Las WCAG nivel AA exigen un contraste mínimo de 4,5:1 para el texto normal y 3:1 para el texto grande (a partir de 18,66 px en negrita o 24 px normal). El verde de marca de CicloUrbano, #12805c, sobre blanco alcanza aproximadamente 4,8:1, así que cumple. Cualquier comprobador de contraste en línea o el panel de accesibilidad del navegador te da el dato en un segundo.

  1. Atributos ARIA de uso común

ARIA (Accessible Rich Internet Applications) es un conjunto de atributos que añaden semántica cuando el HTML no llega. Son un complemento, no un sustituto.

Atributo Qué hace Ejemplo
aria-label Da un nombre accesible cuando no hay texto visible <button aria-label="Cerrar">✕</button>
aria-labelledby Toma el nombre de otro elemento por su id <section aria-labelledby="titulo-catalogo">
aria-describedby Asocia una descripción o un mensaje de error Campo + texto de ayuda
aria-live Anuncia los cambios de contenido de esa región Confirmación de reserva
aria-invalid Marca un campo como no válido Campo con error de validación
aria-hidden Oculta un elemento a las tecnologías de asistencia Iconos decorativos
aria-expanded Indica si un desplegable está abierto Botón que abre un menú
aria-current Señala el elemento actual de un conjunto aria-current="page" en el enlace activo del menú
aria-disabled Indica deshabilitado sin quitar el foco Botón que explica por qué no se puede pulsar
role Cambia el papel semántico del elemento role="button", role="alert"

La regla de oro: no ARIA es mejor que mal ARIA

Es la primera de las cinco reglas oficiales del uso de ARIA, y significa exactamente lo que dice: un atributo ARIA mal puesto es peor que no ponerlo, porque miente a la tecnología de asistencia y esta se cree la mentira.

{/* ✘ MAL: ARIA innecesario sobre un elemento que ya lo hace */}
<button role="button" aria-label="Reservar">Reservar</button>

{/* ✔ BIEN: el HTML ya dice todo lo necesario */}
<button>Reservar</button>

{/* ✘ PEOR: la etiqueta ARIA contradice al texto visible */}
<button aria-label="Cancelar">Reservar</button>

Ese último caso es especialmente dañino: quien ve la pantalla lee «Reservar» y quien la escucha oye «Cancelar». Y quien usa control por voz dirá «pulsar Reservar» y no ocurrirá nada, porque para el sistema ese botón se llama «Cancelar».

Las otras cuatro reglas, resumidas:

  1. Usa el elemento HTML nativo antes que ARIA.
  2. No cambies la semántica nativa: <h2 role="button"> es un despropósito.
  3. Todo control ARIA debe ser usable con teclado.
  4. No pongas aria-hidden="true" en algo enfocable: crearías un elemento invisible al lector pero alcanzable con Tab.

  1. Cómo comprobar que funciona

Prueba manual con Tab: dos minutos, la mitad de los problemas

Guarda el ratón y recorre la pantalla solo con el teclado. Comprueba:

  1. ¿Puedo llegar a todo? Cada botón, enlace y campo debe ser alcanzable.
  2. ¿Veo dónde estoy? El indicador de foco debe ser visible en todo momento.
  3. ¿El orden tiene sentido? Debe seguir el orden visual de lectura.
  4. ¿Puedo activar todo con Enter o Espacio?
  5. ¿Me quedo atrapado? Si el foco entra en algo y no puede salir con Tab, hay un fallo grave.

En CicloUrbano el recorrido correcto es este:

flowchart LR
    A["Saltar al contenido<br/>(visible al enfocar)"] --> B["Enlaces de la cabecera<br/>Catálogo · Estaciones · Mis reservas"]
    B --> C["Botones del SelectorTipo<br/>Todas · Urbanas · Eléctricas · De carga"]
    C --> D["Botón «Reservar»<br/>de cada tarjeta, en orden"]
    D --> E["Campos del formulario<br/>bicicleta → fecha → horas → condiciones"]
    E --> F["Botón «Crear reserva»"]
    F --> G["Enlaces del pie"]

eslint-plugin-jsx-a11y

Detecta en tiempo de escritura una buena parte de los errores de esta lección:

npm install --save-dev eslint-plugin-jsx-a11y
{
  "extends": ["plugin:jsx-a11y/recommended"],
  "plugins": ["jsx-a11y"]
}

Avisa, entre otras cosas, de imágenes sin alt, de elementos no interactivos con manejadores de clic, de etiquetas sin campo asociado, de tabIndex positivos y de atributos ARIA inválidos. En proyectos creados con las plantillas de React ya suele venir configurado.

Auditoría con el navegador

Las herramientas de desarrollo traen dos utilidades imprescindibles:

  • Lighthouse (pestaña del mismo nombre en Chrome y Edge): ejecuta una auditoría automática y puntúa la accesibilidad, con la lista de problemas detectados y cómo corregirlos.
  • Árbol de accesibilidad (dentro del inspector de elementos): muestra cómo ve la página una tecnología de asistencia —el nombre, el rol y el estado de cada elemento—. Es la forma más directa de comprobar si tu aria-label está surtiendo efecto.

También existen extensiones especializadas como axe DevTools o WAVE, que dan informes más detallados.

Advertencia importante: las herramientas automáticas detectan aproximadamente entre el 30 % y el 40 % de los problemas reales. Pueden decirte que falta un alt, pero no si el texto que has escrito es útil; pueden verificar el contraste, pero no si el orden de foco tiene sentido. La prueba con Tab y, cuando sea posible, con un lector de pantalla real (NVDA en Windows, VoiceOver en macOS e iOS, TalkBack en Android) sigue siendo insustituible.

Existe además la posibilidad de automatizar comprobaciones de accesibilidad en las pruebas, con herramientas como jest-axe integradas en la batería de pruebas del proyecto. Es una práctica excelente y se menciona aquí para que sepas que existe; el terreno de las pruebas es el Módulo 9.

Errores Comunes y Consejos

  • <div onClick> en lugar de <button>. No es enfocable, no responde al teclado y no se anuncia como control. Es, con diferencia, el fallo de accesibilidad más común en React.
  • outline: none sin sustituto. Deja a quien navega con teclado sin saber dónde está. Usa :focus-visible con un contorno propio.
  • tabIndex positivo. Rompe el orden de tabulación de toda la página. Solo 0 y -1.
  • Poner tabIndex a elementos que ya son enfocables. Un <button tabIndex={0}> es redundante y confuso.
  • Etiquetas sin asociar. Un <p> encima del campo no es una etiqueta. <label htmlFor> con el id correspondiente, y recuerda que en JSX es htmlFor, no for.
  • id duplicados en listas generadas con map. Rompen la asociación etiqueta-campo. Genéralos a partir del id del dato.
  • Mensajes de error solo visuales. Sin role="alert" no se anuncian al aparecer, y sin aria-describedby no se leen al enfocar el campo.
  • Una región aria-live que aparece junto con su contenido. No se anuncia. El contenedor debe estar siempre en el DOM.
  • aria-live="assertive" para todo. Interrumpe constantemente y acaba siendo insoportable. Reserva el modo asertivo para lo urgente.
  • Imágenes sin alt, o con «imagen de…». Sin alt se lee la URL; el prefijo es redundante porque el lector ya anuncia que es una imagen.
  • Botones con solo un icono y sin nombre accesible. Se anuncian como «botón» a secas. aria-label obligatorio.
  • Transmitir información solo con color. Añade siempre texto o un símbolo.
  • Encabezados elegidos por su tamaño. Rompen el mapa de la página. El nivel es jerarquía; el tamaño se decide en el CSS.
  • ARIA redundante o contradictorio. <button role="button"> sobra; una aria-label distinta del texto visible es un fallo grave. No ARIA es mejor que mal ARIA.
  • Consejo: prueba con Tab cada vez que termines un componente. Dos minutos que ahorran auditorías enteras.
  • Consejo: instala eslint-plugin-jsx-a11y el primer día del proyecto. Corrige mientras escribes en lugar de al final.
  • Consejo: escribe el marcado en el orden lógico de lectura y usa CSS para colocarlo, no tabIndex para reordenarlo.

Ejercicios

Ejercicio 1

Este componente tiene seis problemas de accesibilidad. Identifícalos, explica a quién afecta cada uno y escribe la versión corregida.

function TarjetaEstacionInteractiva({ estacion, ocupadas, alSeleccionar }) {
  return (
    <div className="tarjeta" onClick={() => alSeleccionar(estacion)}>
      <img src={`/img/estaciones/${estacion.id}.webp`} />

      <p className="titulo-grande">{estacion.nombre}</p>

      <span className={ocupadas === estacion.plazas ? 'punto-rojo' : 'punto-verde'} />

      <div role="button" onClick={() => alSeleccionar(estacion)}>
        Ver detalle
      </div>

      <button aria-label="Cerrar">Ver en el mapa</button>
    </div>
  );
}
.tarjeta:focus { outline: none; }

Ejercicio 2

Toma el campo «Bicicleta» del FormularioReserva de la lección 03-05 y hazlo completamente accesible. Debe incluir:

  • Etiqueta correctamente asociada.
  • Un texto de ayuda vinculado: «Solo se muestran las bicicletas disponibles ahora mismo.»
  • aria-invalid cuando el campo tenga un error visible.
  • El mensaje de error asociado y anunciado al aparecer.
  • Un aria-describedby que combine ayuda y error cuando ambos existan.

Escribe también las clases CSS necesarias para que el estado de error se distinga sin depender del color.

Ejercicio 3

Diseña el recorrido de teclado completo de la pantalla principal de CicloUrbano y descríbelo paso a paso. Después responde:

  1. ¿Dónde colocarías una región aria-live y qué anunciaría en cada caso?
  2. ¿Qué debería ocurrir al pulsar Escape en cada zona de la pantalla?
  3. Si la lista de bicicletas tuviera cien tarjetas con un botón cada una, ¿qué problema de navegación aparecería y cómo lo resolverías sin romper nada?

Soluciones

Solución 1.

Los seis problemas:

Problema A quién afecta
<div onClick> como tarjeta pulsable Quien navega con teclado no llega; el lector de pantalla no anuncia que sea interactivo
<img> sin alt El lector de pantalla lee la URL del fichero
<p className="titulo-grande"> en lugar de un encabezado Quien navega saltando entre encabezados no encuentra la estación
El punto de color como única señal de ocupación Quien no distingue rojo y verde no recibe la información
<div role="button"> sin tabIndex ni manejador de teclado Se anuncia como botón pero no se puede alcanzar ni activar con teclado: peor que no poner el role
aria-label="Cerrar" que contradice el texto «Ver en el mapa» Quien escucha oye algo distinto de lo que se ve; el control por voz no funciona

Y en el CSS, outline: none sin sustituto elimina el indicador de foco.

// src/componentes/TarjetaEstacionInteractiva.jsx
import { clases } from '../utilidades/clases.js';
import estilos from './TarjetaEstacionInteractiva.module.css';

/**
 * Tarjeta de una estación de CicloUrbano con acciones.
 * Props:
 *  - estacion (objeto, obligatorio) { id, nombre, barrio, plazas }
 *  - ocupadas (número, opcional, por defecto 0)
 *  - alSeleccionar, alVerMapa (funciones, opcionales)
 */
function TarjetaEstacionInteractiva({ estacion, ocupadas = 0, alSeleccionar, alVerMapa }) {
  const completa = ocupadas === estacion.plazas;
  const libres = estacion.plazas - ocupadas;

  return (
    <article className={estilos.tarjeta}>
      <img
        src={`/img/estaciones/${estacion.id}.webp`}
        alt={`Estación ${estacion.nombre}, en el barrio ${estacion.barrio}`}
      />

      {/* Encabezado real: nivel 3 dentro de la sección de estaciones */}
      <h3 className={estilos.titulo}>{estacion.nombre}</h3>

      {/* Color + símbolo + TEXTO: tres canales */}
      <p className={clases(estilos.ocupacion, completa ? estilos.completa : estilos.libre)}>
        <span aria-hidden="true">{completa ? '✕' : '●'} </span>
        {completa ? 'Estación completa' : `${libres} plazas libres`}
      </p>

      {/* Botones REALES: enfocables, activables con Enter y Espacio */}
      <button type="button" onClick={() => alSeleccionar(estacion)}>
        Ver detalle de {estacion.nombre}
      </button>

      <button type="button" onClick={() => alVerMapa(estacion)}>
        Ver en el mapa
      </button>
    </article>
  );
}

export default TarjetaEstacionInteractiva;
/* src/componentes/TarjetaEstacionInteractiva.module.css */
.tarjeta {
  background: var(--color-superficie);
  border: 1px solid var(--color-borde);
  border-radius: var(--radio);
  padding: var(--espacio);
}

/* El foco se sustituye, nunca se elimina */
.tarjeta button:focus-visible {
  outline: 3px solid var(--color-marca);
  outline-offset: 2px;
}

.libre { color: var(--color-marca); }
.completa { color: var(--color-mantenimiento); font-weight: 700; }

La decisión de fondo: se ha eliminado el onClick de la tarjeta entera. El área pulsable son dos botones de verdad con texto descriptivo, así que no hacen falta role, tabIndex, onKeyDown ni aria-label.

Solución 2.

<div className={estilos.campo}>
  <label htmlFor="bicicletaId">Bicicleta</label>

  <select
    id="bicicletaId"
    name="bicicletaId"
    required
    value={datos.bicicletaId}
    onChange={manejarCambio}
    onBlur={manejarBlur}
    aria-invalid={mostrarError('bicicletaId')}
    aria-describedby={
      mostrarError('bicicletaId')
        ? 'bicicleta-ayuda bicicleta-error'
        : 'bicicleta-ayuda'
    }
    className={clases(estilos.control, mostrarError('bicicletaId') && estilos.invalido)}
  >
    <option value="">— Elige una bicicleta —</option>
    {disponibles.map((bicicleta) => (
      <option key={bicicleta.id} value={bicicleta.id}>
        {bicicleta.modelo} · {bicicleta.precioHora.toFixed(2).replace('.', ',')} €/h
      </option>
    ))}
  </select>

  <p id="bicicleta-ayuda" className={estilos.ayuda}>
    Solo se muestran las bicicletas disponibles ahora mismo.
  </p>

  {mostrarError('bicicletaId') && (
    <p id="bicicleta-error" className={estilos.error} role="alert">
      <span aria-hidden="true">⚠ </span>
      {errores.bicicletaId}
    </p>
  )}
</div>
/* src/componentes/FormularioReserva.module.css */
.control {
  border: 1px solid var(--color-borde);
  border-radius: var(--radio);
  padding: 0.5rem;
  width: 100%;
}

.control:focus-visible {
  outline: 3px solid var(--color-marca);
  outline-offset: 2px;
}

/* Estado de error: color + GROSOR de borde + fondo, no solo color */
.invalido {
  border-color: var(--color-mantenimiento);
  border-width: 2px;
  background-color: #fdf2f2;
}

.ayuda {
  font-size: 0.85rem;
  color: var(--color-texto);
  opacity: 0.8;
  margin: 0.25rem 0 0;
}

/* El mensaje lleva símbolo y texto: el color es el tercer canal, no el único */
.error {
  font-size: 0.9rem;
  font-weight: 700;
  color: var(--color-mantenimiento);
  margin: 0.25rem 0 0;
}

Las tres señales del estado de error, independientes entre sí: el borde más grueso y el fondo (perceptibles sin distinguir el color), el símbolo ⚠ y el texto del mensaje. Y para el lector de pantalla, aria-invalid lo anuncia como campo no válido, aria-describedby le asocia ayuda y error, y role="alert" hace que el mensaje se lea en cuanto aparece.

Solución 3.

Recorrido de teclado propuesto:

  1. Enlace «Saltar al contenido», oculto hasta recibir foco, que lleva directamente al <main>. Es el primer elemento de la página y evita tener que recorrer toda la navegación en cada visita.
  2. Cabecera: enlaces «Catálogo», «Estaciones», «Mis reservas», con aria-current="page" en el activo.
  3. SelectorTipo: los cuatro botones de filtro, en orden.
  4. ListaBicicletas: por cada tarjeta, su botón «Reservar».
  5. FormularioReserva: bicicleta → fecha → horas → casilla de condiciones → botón «Crear reserva».
  6. Pie de página: sus enlaces.

1. Regiones aria-live: dos, ambas presentes en el DOM desde el primer render.

Región Rol Qué anuncia
Recuento del catálogo role="status" «Se han encontrado 3 bicicletas» al cambiar el filtro
Confirmación de reserva role="status" «Reserva creada para 2 horas» tras un envío correcto

Los errores del formulario no necesitan una región compartida: cada mensaje ya lleva su role="alert".

2. Comportamiento de Escape:

Zona Acción de Escape
SelectorTipo Volver al filtro «Todas» (ya implementado en 03-01)
Un campo del formulario Restaurar el valor anterior del campo, o no hacer nada. Nunca vaciar el formulario entero: sería una pérdida de datos irreversible y sorprendente
Un diálogo o menú desplegable Cerrarlo y devolver el foco al elemento que lo abrió

3. El problema de las cien tarjetas: para llegar al formulario habría que pulsar Tab cien veces. Es una barrera real, y las soluciones que no valen son tabIndex positivos (rompen todo el orden) o quitar los botones del recorrido con tabIndex={-1} (los haría inalcanzables). Lo que sí funciona:

  • Enlaces de salto antes y después de la lista: «Saltar la lista de bicicletas» / «Volver al filtro», visibles solo al recibir el foco.
  • Encabezados y regiones bien marcados, para que quien usa lector de pantalla salte por estructura en lugar de por tabulación.
  • Paginación o carga progresiva, que además mejora el rendimiento y beneficia a todo el mundo.
  • Un <section aria-labelledby> para la lista, de forma que se anuncie como región identificable y se pueda saltar de una vez.

Conclusión

Con esta lección cierras el Módulo 3 y saldas todas las deudas que fue dejando por el camino. Sabes por qué la accesibilidad importa —personas, obligación legal y calidad general— y conoces los cuatro principios de las WCAG: perceptible, operable, comprensible y robusto. Sobre todo, has interiorizado la regla que resuelve la mayor parte de los problemas antes de que existan: usa el elemento HTML correcto. Un <button> trae de fábrica el foco, el teclado, el rol y el estado; un <div> con onClick obliga a reconstruir cinco cosas y basta olvidar una para dejar a alguien fuera.

Has aplicado eso a todo lo construido en el módulo: la tarjeta pulsable de la lección 03-01 pasa a tener botones de verdad; el FormularioReserva asocia cada <label htmlFor> a su id, vincula la ayuda con aria-describedby, marca los campos con aria-invalid y anuncia los mensajes con role="alert" —cerrando exactamente lo que quedó pendiente en 03-05—; EtiquetaEstado transmite el estado por tres canales independientes, color, símbolo y texto, para no dejar fuera a quien no distingue los colores; y una región aria-live avisa de lo que cambia sin que nadie mueva el foco. Sabes gestionar el orden de tabulación, cuándo usar tabIndex={0} y tabIndex={-1} y por qué los valores positivos están prohibidos, y que el indicador de foco se sustituye con :focus-visible pero jamás se elimina. Conoces los atributos ARIA habituales y la regla que los gobierna: no ARIA es mejor que mal ARIA. Y sabes comprobarlo: con Tab en dos minutos, con eslint-plugin-jsx-a11y mientras escribes y con Lighthouse y el árbol de accesibilidad del navegador, recordando que las herramientas automáticas solo detectan un tercio de los problemas reales.

Haciendo balance del módulo completo: CicloUrbano ha dejado de mirarse y ha empezado a responder. Los eventos conectan la interfaz con la lógica, con manejadores bien nombrados, eventos sintéticos y propagación bajo control. El renderizado condicional hace que cada tarjeta muestre lo que corresponde a su estado y que el catálogo sepa qué decir cuando no hay nada. Las listas con map y claves estables han eliminado la repetición escrita a mano y han cerrado la deuda de la reconciliación. Los formularios controlados recogen datos con el estado como única fuente de verdad, la validación los filtra con reglas de negocio de verdad, y la accesibilidad hace que todo ello llegue a cualquier persona.

Pero queda un límite que hemos rozado en cada lección sin poder cruzarlo. El SelectorTipo guarda el tipo elegido en su propio estado y avisa al padre, pero sigue sin filtrar la lista, porque ListaBicicletas no puede ver ese estado. TarjetaBicicleta avisa con alSeleccionar y App se limita a hacer un console.log, porque no hay dónde guardar la bicicleta seleccionada. El estado es privado de cada componente, y eso es justo lo que impide que las piezas trabajen juntas. La solución tiene nombre y abre el Módulo 4: Conceptos Avanzados de Componentes. La próxima lección es Elevando el Estado, y con ella el catálogo de CicloUrbano filtrará de verdad.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados