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:
- Un
<button>de verdad. Recibe foco, se activa con Enter y con Espacio, y el lector de pantalla lo anuncia como botón. Undivno te da nada de eso. Terminas agregandotabindex,roley dos manejadores de teclado para reconstruir lo que el navegador ya tenía. aria-expandeden el botón, sincronizado con la lista. Así sabe quien usa lector de pantalla si está abierto o cerrado.aria-controlsapuntando a la lista, para que la relación sea explícita.- La lista oculta de verdad cuando está cerrada. Con
hiddenodisplay: none, no solo conopacity: 0. Si no, con Tab llegas a links que no se ven. - 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.
- Tab sigue funcionando normal. No atrapes el foco en un disclosure. Si el foco sale del componente, ciérralo.
- 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:
Cargando demo…
<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?