← El DOM: la web que responde

Sesión 2

Eventos y elementos dinámicos

Tres horas. Media hora para entender cómo se escucha lo que hace una persona, cómo viaja un evento por el árbol y cómo se crea un elemento, y dos horas y media haciendo que tu página reaccione y genere su propio marcado.

Hoy · Hoja de ruta

  1. 1. Aprende: Qué es un evento y dónde se escucha, qué información trae, qué es delegar, y cómo se crea e inserta un elemento sin perder la semántica.
  2. 2. Haz: Haz que tu página reaccione al clic, al teclado y al envío del formulario; atiende todas las tarjetas con un solo manejador; genera la tarjeta desde un objeto.
  3. 3. Comprueba: Todo lo que funciona con ratón funciona con teclado, y el HTML generado pasa el validador.

Antes de empezar · 5 minutos, sin apuntes

  1. Enumera cinco cosas que puede hacer una persona en tu página. ¿Cuáles se pueden hacer sin ratón?
  2. Si tienes cincuenta botones iguales, ¿registrarías cincuenta escuchas?
  3. Si generas HTML desde código, ¿quién garantiza que sigue siendo semántico?

Se explica

25 minutos · conceptos y demostración

Tres piezas que hoy encajan entre sí: escuchar, averiguar quién provocó la acción, y crear los elementos sobre los que se actuará. La tercera obliga a la primera a hacerse de una forma concreta, y de ahí sale la delegación.

Escuchar

const boton = document.querySelector("[data-js='ordenar']");

boton.addEventListener("click", () => {
  console.log("Han pulsado");
});

Tres piezas: el elemento que escucha, el tipo de evento y la función que se ejecutará. Esa función es un callback, el concepto de la sesión 3 de la UD3: tú no la llamas, la llama el navegador cuando ocurre algo.

Evento Ocurre cuando
click Se pulsa, con ratón o con teclado sobre un control enfocable
input Cambia el contenido de un campo, con cada tecla
change El campo pierde el foco tras cambiar, o cambia una selección
submit Se envía un formulario
keydown Se pulsa una tecla
focus / blur Un elemento gana o pierde el foco
DOMContentLoaded El documento está listo

Escucha en el elemento correcto

Para un formulario, el evento es submit en el formulario, no click en el botón. Solo así funcionan también la tecla Intro dentro de un campo y cualquier otra forma de enviar.

Y para un control que se pulsa, usa un elemento que sea pulsable de verdad. Un div con un manejador de clic no recibe el foco, no responde a la tecla Intro y no se anuncia como control: es inaccesible por construcción.

const formulario = document.querySelector("[data-js='contacto']");

formulario.addEventListener("submit", (evento) => {
  evento.preventDefault();      // impide que se recargue la página
  console.log("Enviando…");
});

Sin preventDefault, el navegador hace lo suyo: envía y recarga. Verás tu mensaje en consola durante un instante y desaparecerá. Es el desconcierto clásico de esta sesión.

Qué trae el evento, y por dónde pasa

catalogo.addEventListener("click", (evento) => {
  evento.target;            // el elemento exacto donde se pulsó
  evento.currentTarget;     // el elemento que escucha
  evento.type;              // "click"
  evento.key;               // en eventos de teclado
  evento.preventDefault();
  evento.stopPropagation();
});

La pareja target / currentTarget es la clave de la sesión: puedes escuchar en el contenedor y averiguar dentro cuál de sus hijos recibió la acción. Eso es posible porque un clic no ocurre solo en un elemento, sino que recorre el árbol:

El camino de un evento
  1. Captura · del documento hacia abajo
  2. Objetivo · el elemento pulsado
  3. Burbujeo · hacia arriba, hasta el documento

El burbujeo explica también un fallo característico: pulsar en un botón dentro de una tarjeta que también escucha el clic dispara los dos manejadores.

stopPropagation es el último recurso

Detener la propagación arregla el síntoma y deja una trampa: cualquier manejador que alguien registre más arriba —cerrar un menú, un contador de uso— dejará de enterarse, y quien lo escriba no entenderá por qué.

Antes de detenerla, comprueba si el manejador de arriba puede filtrar por target. Casi siempre puede.

Delegar

catalogo.addEventListener("click", (evento) => {
  const boton = evento.target.closest("[data-accion='añadir']");
  if (!boton) return;                     // el clic no era en un botón nuestro

  const id = Number(boton.dataset.id);
  añadirAlCarrito(id);
});

Tres ventajas, y son grandes: una sola escucha en lugar de cincuenta; funciona con las tarjetas que tu código creará después; y no hay que registrar nada de nuevo tras volver a pintar.

closest

Sube por los antepasados desde el elemento pulsado hasta encontrar uno que case con el selector. Resuelve el problema de que el clic caiga en un icono o en el texto de dentro del botón, y no en el botón mismo.

El teclado se atiende con su propio evento cuando hace falta una tecla concreta:

buscador.addEventListener("keydown", (evento) => {
  if (evento.key === "Escape") limpiarBusqueda();
});

Si delegas clics en elementos que son botones de verdad, en cambio, el teclado ya funciona: pulsar Intro sobre un botón genera un click. Es otra razón para usar el elemento correcto en lugar de un div decorado.

Crear, rellenar, insertar

const item = document.createElement("li");
item.className = "producto";
item.dataset.id = producto.id;

const titulo = document.createElement("h3");
titulo.textContent = producto.nombre;

item.append(titulo);
catalogo.append(item);          // al final
Método Qué hace
append Añade al final del contenido
prepend Añade al principio
before / after Inserta como hermano
remove Se elimina a sí mismo
replaceChildren Sustituye la totalidad del contenido en una sola operación

Añadir cincuenta elementos uno a uno hace que el navegador recalcule la página cincuenta veces. Se construyen aparte y se insertan juntos:

const fragmento = document.createDocumentFragment();

for (const producto of productos) {
  fragmento.append(crearTarjeta(producto));
}

catalogo.replaceChildren(fragmento);

Para vaciar un contenedor:

catalogo.replaceChildren();        // lo deja vacío
catalogo.innerHTML = "";           // funciona, pero pasa por el analizador

replaceChildren() es más claro y más seguro, y admite también los nuevos hijos de una vez.

Lo que genera tu código pasa las mismas normas de la UD1

Es fácil que la lista de productos acabe siendo una pila de contenedores genéricos con clases. Si al escribirlo a mano usabas una lista de artículos con su encabezado, su imagen con texto alternativo y su precio, el código tiene que generar exactamente eso.

La comprobación: abre Elements, copia el marcado generado, pégalo en el validador del W3C. La jerarquía de encabezados sigue siendo exigible sobre ese marcado generado.

La función que lo construye devuelve el elemento sin insertarlo: quien la llama decide dónde va. Es la misma regla de la UD3 —calcular y devolver— aplicada al DOM.

export function crearTarjeta(producto) {
  const item = document.createElement("li");
  item.className = "producto";
  item.dataset.id = producto.id;

  const titulo = document.createElement("h3");
  titulo.textContent = producto.nombre;

  const imagen = document.createElement("img");
  imagen.src = producto.imagen;
  imagen.alt = producto.textoAlternativo;
  imagen.loading = "lazy";

  const precio = document.createElement("p");
  precio.className = "precio";
  precio.textContent = formatearPrecio(producto.precio);

  item.append(imagen, titulo, precio);
  return item;
}

El diagnóstico de esta unidad

Cuando algo no responde
  1. Un console.log como primera línea del manejador
  2. ¿Aparece? El evento llega: el fallo es de tu lógica
  3. ¿No aparece? ¿Existe el elemento, o era null?
  4. ¿El nombre del evento está bien escrito?
  5. ¿El elemento se creó después de registrar la escucha?

Ese último caso es el que resuelve la delegación, y por eso los tres bloques de hoy son en realidad uno.

Se trabaja

150 minutos · práctica sobre tu propio proyecto

Los tres primeros pasos construyen la interacción; los dos últimos comprueban lo que casi nadie comprueba: que el marcado generado siga siendo válido y que todo se pueda usar sin ratón.

Paso 1 · Tu página reacciona · 30 min

  1. Un botón que alterna la vista compacta del catálogo.
  2. Un campo de búsqueda que escribe en consola lo tecleado con input.
  3. El formulario de contacto que evita la recarga y muestra los valores.
  4. Un botón que solo funcione una vez, con { once: true }.
  5. Prueba la diferencia entre input y change en el mismo campo, y anótala.
Consultar · quitar un manejador
function alPulsar() { /* ... */ }

boton.addEventListener("click", alPulsar);
boton.removeEventListener("click", alPulsar);

Para poder quitarlo hace falta la misma referencia de función, así que una flecha escrita en el sitio no se puede retirar. También está la opción { once: true } para que se ejecute una sola vez.

Antes de continuar: el formulario no recarga la página, y sabes decir en qué línea lo has impedido.

Paso 2 · Un manejador para todos · 40 min

  1. Añade a cada tarjeta de tu catálogo un botón con data-accion y data-id.
  2. Registra una sola escucha en el contenedor.
  3. Usa closest para localizar el botón y dataset para el identificador.
  4. Añade una segunda acción y distínguela por su data-accion.
  5. Comprueba con el teclado que ambas funcionan.
  6. Registra una escucha en el contenedor y otra en la tarjeta, y observa el orden en que se disparan. Anótalo: es el burbujeo, visto en tu propia página.

Paso 3 · La tarjeta generada · 45 min

Es el trabajo central de la sesión.

  1. Escribe crearTarjeta(producto) en js/render.js, que devuelva el elemento sin insertarlo.
  2. Genera con ella las tarjetas de tres productos y añádelas con un fragmento.
  3. Añade un botón de acción con sus atributos data-.
  4. Escribe vaciarCatalogo() con replaceChildren() y comprueba que las escuchas delegadas siguen funcionando después de vaciar y volver a pintar.
  5. Añade el estado «agotado» como clase para el CSS y como texto para quien no ve la pantalla. No basta con el color.
Consultar · la otra forma, con template

HTML tiene un elemento pensado para esto: <template> guarda marcado que no se pinta hasta que lo clonas.

<template data-js="plantilla-producto">
  <li class="producto">
    <h3></h3>
    <p class="precio"></p>
  </li>
</template>
const plantilla = document.querySelector("[data-js='plantilla-producto']");
const copia = plantilla.content.cloneNode(true);
copia.querySelector("h3").textContent = producto.nombre;

La estructura vive en el HTML y el código solo rellena. Tiene la ventaja de que el marcado se ve y se valida donde siempre, y el inconveniente de repartir la tarjeta entre dos ficheros. Las dos formas son correctas.

Paso 4 · Valida lo que ha escrito tu código · 15 min

  1. Compara el marcado generado en el paso 3 con el que tenías escrito a mano en la UD1. ¿Es igual de semántico?
  2. Abre Elements, copia el bloque del catálogo entero y pásalo por validator.w3.org.
  3. Comprueba la jerarquía de encabezados del documento con las tarjetas ya pintadas. Si tu h3 cuelga de un h1 sin h2 en medio, el salto lo ha creado tu JavaScript.
  4. Revisa que cada imagen generada tiene su alt, y que ese alt sale de un campo de datos y no de una cadena fija.
Comprobación Resultado Corrección
Validador del W3C sobre el marcado generado
Jerarquía de encabezados sin saltos
Todas las imágenes con alt que aporta algo
Los botones son button, no div

Paso 5 · Todo esto, sin ratón · 20 min

Suelta el ratón y recorre la página con Tab, Shift + Tab, Intro y Espacio.

  1. ¿Puedes llegar a los botones generados por tu código?
  2. ¿Se ve el foco sobre ellos, con el :focus-visible de la UD2?
  3. ¿Funcionan las dos acciones delegadas?
  4. Añade un atajo: Escape limpia la búsqueda, e Intro en el buscador aplica el filtro.
  5. Después de vaciar y repintar el catálogo, ¿dónde se ha quedado el foco? Anótalo. Es un problema real que resolverás en la sesión 6.

Ampliación si has completado el trabajo

Primero termina y comprueba los cinco pasos. Los dos retos miden el coste de dos atajos que parecen inofensivos.

Reto 1 · Cuánto cuesta un botón falso

<div class="boton" onclick="comprar()">Comprar</div>
  1. Móntalo y comprueba con el teclado qué no hace: enumera las cuatro cosas que un <button> trae de fábrica y este no.
  2. Recontrúyelas una a una: hazlo enfocable, que responda a Intro y a Espacio, que se anuncie como botón, y que tenga foco visible. Cuenta las líneas de HTML, CSS y JavaScript que te ha costado.
  3. Compara ese recuento con escribir <button type="button">.
  4. Queda todavía una diferencia que no has podido reconstruir del todo: averigua qué ocurre con ese falso botón dentro de un formulario, y con el modo de alto contraste del sistema.
  5. Escribe en tres líneas por qué este ejercicio no va de accesibilidad, sino de no reprogramar lo que la plataforma ya hace. Es la misma conclusión que sacaste en la UD1 con details y progress.

Reto 2 · El menú que deja de cerrarse

Monta esta situación, que es completamente habitual:

· Un menú desplegable que se abre con un botón.
· Una escucha en `document` que lo cierra al pulsar en cualquier otro sitio.
· Un catálogo de tarjetas; cada tarjeta abre el detalle al pulsarla.
· Dentro de cada tarjeta, un botón «Añadir» que NO debe abrir el detalle.
  1. Resuelve el botón «Añadir» con stopPropagation y comprueba que funciona.
  2. Ahora abre el menú y pulsa ese botón. El menú no se cierra. Explica exactamente por qué, siguiendo el camino del evento.
  3. Resuélvelo de nuevo sin stopPropagation: el manejador de la tarjeta debe filtrar por target y salir pronto si el clic venía de un botón de acción.
  4. Comprueba que ahora el menú sí se cierra y que el detalle sigue sin abrirse.
  5. Enuncia la regla en una frase: quién debe decidir si un evento le incumbe, el que lo emite o el que lo escucha.
Objetivo mínimoTres eventos funcionando con submit sin recarga, una escucha delegada que distingue dos acciones, y tarjetas generadas semánticas.
Si lo tienesLa tabla de validación del marcado generado contestada y el recorrido completo con teclado, atajos incluidos.
RetoEl coste del botón falso medido en líneas, y el conflicto del menú resuelto sin stopPropagation.

Cierre

5 minutos · comprobación y recuerdo

Lista de verificación de la sesión

  • Usas submit en el formulario y preventDefault.
  • Distingues input de change.
  • Usas una sola escucha delegada por contenedor, con closest.
  • Tu página responde al ratón y al teclado por igual.
  • Generas elementos con marcado semántico, validado desde Elements.
  • Insertas en bloque con un fragmento, no de uno en uno.

Antes de cerrar · 3 minutos, sin mirar

  1. ¿Por qué escuchamos submit y no el clic del botón?
  2. ¿Qué hace preventDefault?
  3. ¿Qué diferencia hay entre target y currentTarget?
  4. ¿Qué ventaja tiene delegar, y para qué sirve closest?
  5. ¿Por qué evitamos stopPropagation?
  6. ¿Por qué se insertan los elementos con un fragmento?
Ver respuestas

1 · Porque un formulario también se envía con Intro, y el clic no cubre ese caso.

2 · Cancela el comportamiento por defecto del navegador para ese evento.

3 · target es el elemento exacto donde ocurrió; currentTarget, el que tiene registrada la escucha.

4 · Una sola escucha que funciona también con los elementos creados después; closest sube desde el punto pulsado hasta el contenedor que interesa.

5 · Porque impide que otros manejadores más arriba se enteren del evento, y eso rompe cosas a distancia.

6 · Porque cada inserción individual obliga al navegador a recalcular la página.

Microprueba semanal 2 · 5–10 minutos

Individual, sin IA y sin apuntes.

  1. Escribe la escucha que impide que un formulario recargue la página.
  2. Explica por qué una escucha delegada funciona con elementos que aún no existen.
  3. ¿Qué diferencia hay entre target y currentTarget?