Todas las entradas

12 estudiantes, un dropdown y la pregunta que la IA no pudo responder

La mitad de mis alumnos hizo un dropdown accesible con IA y la otra mitad sin ella. Los de IA terminaron antes. Luego lo probamos con teclado.

La semana pasada 12 estudiantes recibieron el mismo ejercicio: hacer un dropdown accesible con HTML, CSS y JavaScript puros. Sin framework, sin librería de componentes. La mitad podía usar IA. La otra mitad no.

El grupo con IA terminó en 25 minutos en promedio. El otro tardó 60.

Después les pedí que usaran su dropdown solo con el teclado. Nada de mouse ni trackpad.

5 de los 6 dropdowns hechos con IA atrapaban el foco o se saltaban las opciones por completo. 4 de los 6 del grupo más lento funcionaban.

Lo que se me quedó grabado

Lo del teclado no fue lo que más me sorprendió. Lo que se me quedó fue lo que pasó después.

Cuando le pregunté al grupo con IA por qué habían usado un div con onClick en lugar de un button, la mayoría no supo responder. No lo habían elegido. Lo habían aceptado.

El grupo más lento podía explicar cada línea, incluso las que estaban mal. Eso importa más de lo que parece. Si puedes explicar una línea rota, puedes arreglarla. Si nunca elegiste esa línea, ni siquiera sabes por dónde empezar a buscar.

Lo que de verdad necesita un dropdown accesible

"Dropdown" es una palabra que cubre varios patrones distintos, y ahí empieza el problema. Una lista de links que se abre desde un botón es un disclosure. Un reemplazo de <select> es un listbox. Una lista de acciones estilo app es un menu. Cada uno tiene reglas de teclado diferentes, y si los mezclas terminas con algo que se ve bien y se comporta mal.

Para el ejercicio bastaba con un disclosure. Esto es lo que necesita:

  1. Un <button> de verdad. Recibe foco, se activa con Enter y con Espacio, y el lector de pantalla lo anuncia como botón. Un div no te da nada de eso. Terminas agregando tabindex, role y dos manejadores de teclado para reconstruir lo que el navegador ya tenía.
  2. aria-expanded en el botón, sincronizado con la lista. Así sabe quien usa lector de pantalla si está abierto o cerrado.
  3. aria-controls apuntando a la lista, para que la relación sea explícita.
  4. La lista oculta de verdad cuando está cerrada. Con hidden o display: none, no solo con opacity: 0. Si no, con Tab llegas a links que no se ven.
  5. Escape lo cierra y regresa el foco al botón. Sin eso, la persona queda en algún punto de la página sin saber dónde.
  6. Tab sigue funcionando normal. No atrapes el foco en un disclosure. Si el foco sale del componente, ciérralo.
  7. Las flechas son un extra, no un requisito, en un disclosure. Si las agregas, cuida que no rompan Tab.

Esta versión cubre todo eso:

Demo en vivoSelector de idioma accesibleUsa el teclado

Cargando demo…

Demo en vivo: pruébalo con Tab, Enter, las flechas y Escape.
<div class="dropdown">
  <button type="button" aria-expanded="false" aria-controls="lang-list">
    Idioma
  </button>
  <ul id="lang-list" hidden>
    <li><a href="/en">English</a></li>
    <li><a href="/es">Español</a></li>
  </ul>
</div>
const root = document.querySelector(".dropdown");
const button = root.querySelector("button");
const list = root.querySelector("ul");
const links = [...list.querySelectorAll("a")];
 
function setOpen(open) {
  button.setAttribute("aria-expanded", String(open));
  list.hidden = !open;
}
 
button.addEventListener("click", () => {
  setOpen(button.getAttribute("aria-expanded") !== "true");
});
 
root.addEventListener("keydown", (event) => {
  if (event.key === "Escape" && !list.hidden) {
    setOpen(false);
    button.focus();
    return;
  }
 
  // Opcional: las flechas mueven el foco entre los links
  if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
  event.preventDefault();
  setOpen(true);
 
  const step = event.key === "ArrowDown" ? 1 : -1;
  const current = links.indexOf(document.activeElement);
  const next =
    current === -1
      ? step === 1 ? 0 : links.length - 1
      : (current + step + links.length) % links.length;
  links[next].focus();
});
 
// Cierra cuando el foco se va fuera del componente (por ejemplo, con Tab)
root.addEventListener("focusout", (event) => {
  if (event.relatedTarget && !root.contains(event.relatedTarget)) {
    setOpen(false);
  }
});
 
// Cierra al hacer clic en cualquier otra parte de la página
document.addEventListener("click", (event) => {
  if (!root.contains(event.target)) setOpen(false);
});

Un par de detalles. Aquí no hay role="menu". Si lo agregas, le prometes al lector de pantalla un comportamiento de menú (solo flechas, Tab sale del menú, búsqueda por tecleo) que este componente no tiene. Es mejor no poner ARIA que poner ARIA incorrecto.

El manejador de focusout revisa relatedTarget antes de cerrar. Algunos navegadores no le dan foco a un botón cuando le haces clic, así que relatedTarget puede llegar en null. Para ese caso está el manejador de clic en el documento.

Si lo que necesitas es reemplazar un <select>, ese es el patrón listbox y lleva más trabajo: role="listbox", role="option", aria-selected y manejar la opción activa con un tabindex dinámico o con aria-activedescendant. Revisa las WAI-ARIA Authoring Practices antes de construirlo. Y, siendo honesto, pregúntate primero si un <select> nativo no resuelve el problema.

La regla que agregué

Me dedico a escribir React y Next.js, y uso IA todos los días. No le estoy diciendo a nadie que deje de usarla.

Solo agregué una regla a mi clase: puedes pegar código de IA cuando puedas explicar qué está reemplazando.

Si la IA te da un div con onClick, deberías poder decir "esto reemplaza a un botón, y esto es lo que pierdo". Si no puedes decirlo, todavía no estás listo para pegarlo.

Al grupo con IA no le faltaba talento. Se saltaron el paso de decidir. El grupo lento tomó más decisiones, algunas equivocadas, y justo por eso pudo corregirlas.

Lo que yo contrataría

Hoy la velocidad es fácil de conseguir. Cualquiera saca un dropdown en cinco minutos.

La habilidad que yo contrataría es la otra: entender por qué el código es como es. Eso es lo que te permite revisar lo que genera la IA, detectar el div que debió ser un button y probar con teclado antes de publicar.

Si eres mentor o lideras a juniors, me da curiosidad: ¿qué regla pusiste para el uso de IA en tu equipo?