La lección anterior dejó la página de Nómada Tareas mostrando datos reales, pero completamente muda: un cartel bonito con el que no se puede interactuar. Una aplicación es otra cosa. Es un programa que espera y que reacciona: alguien hace clic, escribe en un campo, pulsa una tecla, y el programa responde. Ese modelo de trabajo se llama programación dirigida por eventos, y es la forma natural de programar en el navegador. En esta lección aprenderás a registrar manejadores con addEventListener, a leer la información que trae el objeto Event, a retirar manejadores cuando ya no hacen falta, a distinguir los eventos que más vas a usar, y a evitar la trampa más habitual de todas: construir controles con <div> que dejan fuera a quien no usa el ratón. Al terminar, Marta podrá marcar una tarea como hecha con un clic y Lucía podrá ver solo lo suyo.

Contenido

  1. Qué es un evento y qué es la programación dirigida por eventos
  2. Las tres formas de registrar un manejador
  3. addEventListener a fondo
  4. Las opciones: once, passive, capture, signal
  5. removeEventListener y el problema de la referencia
  6. El objeto Event
  7. preventDefault y las acciones por defecto
  8. Tabla de referencia de eventos
  9. Eventos de teclado y atajos
  10. this dentro de un manejador
  11. Accesibilidad: por qué un <div> con click no basta
  12. Nómada Tareas: marcar como hecha y filtrar por responsable
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. Qué es un evento y qué es la programación dirigida por eventos

Un evento es una notificación de que ha ocurrido algo. Lo genera el navegador y puede venir de una persona (un clic, una tecla, un desplazamiento), del propio navegador (la página terminó de cargar, una imagen falló) o de tu código (los eventos personalizados de 06-04).

Un manejador (o listener) es una función que tú registras para que se ejecute cuando ocurra un evento concreto sobre un elemento concreto. Y aquí está la clave conceptual: tú no llamas a esa función nunca. La registras y te olvidas. Es el navegador quien la llama.

Esto encaja exactamente con el bucle de eventos de 05-07. Cuando registras un manejador de click sobre un botón, no ocurre nada; el hilo principal queda libre. Cuando alguien hace clic, el navegador encola tu función como una macrotarea y el bucle de eventos la ejecutará en cuanto la pila de llamadas esté vacía.

flowchart LR
    A["Marta hace clic<br/>en un botón"] --> B["El navegador crea<br/>un objeto Event"]
    B --> C["Encola el manejador<br/>en la cola de tareas"]
    C --> D{"¿Pila de llamadas<br/>vacía?"}
    D -- "No" --> D
    D -- "Sí" --> E["El bucle de eventos<br/>ejecuta tu función"]
    E --> F["La función modifica<br/>el modelo y el DOM"]

De aquí sale una consecuencia práctica que ya conoces del Módulo 5 pero que ahora se vuelve tangible: si tu manejador tarda, la interfaz se congela. Mientras tu función se ejecuta, el hilo está ocupado, y el navegador no puede ni repintar ni atender otros clics. Un manejador debe hacer poco trabajo y hacerlo rápido; si necesita algo caro, se reparte o se aplaza (Módulo 9).

  1. Las tres formas de registrar un manejador

Históricamente ha habido tres maneras de conectar una función a un evento. Solo una es aceptable hoy, pero conviene reconocer las otras dos porque están en todas partes.

Forma 1: el atributo HTML onclick.

<!-- ✗ No hagas esto -->
<button onclick="marcarHecha(6)">Marcar hecha</button>

El navegador toma esa cadena y la evalúa como código. Mezcla el marcado con el comportamiento, obliga a que marcarHecha sea una función global (lo que rompe con los módulos de 05-04, cuyo ámbito no es global), es imposible de mantener y las políticas de seguridad de contenido modernas lo bloquean directamente.

Forma 2: la propiedad elemento.onclick.

// ✗ Mejor que la anterior, pero sigue siendo limitada
const boton = document.querySelector('#marcar-hecha');
boton.onclick = () => console.log('clic');
boton.onclick = () => console.log('otro clic');   // ← ¡pisa el anterior!
// Solo se ejecuta el segundo.

Está en JavaScript, lo cual ya es una mejora, pero solo admite un manejador por evento y elemento. Si tu código pone uno y una biblioteca pone otro, uno de los dos desaparece silenciosamente.

Forma 3: addEventListener.

// ✓ La única forma correcta
const boton = document.querySelector('#marcar-hecha');
boton.addEventListener('click', () => console.log('clic'));
boton.addEventListener('click', () => console.log('otro clic'));
// Se ejecutan LOS DOS, en el orden en que se registraron.
Forma Separa marcado y lógica Varios manejadores Opciones (once, capture…) Funciona con módulos Veredicto
Atributo onclick="…" No No No No (exige función global) Nunca
Propiedad elem.onclick No No Solo en código muy antiguo
addEventListener Siempre

  1. addEventListener a fondo

La firma es sencilla y siempre la misma:

elemento.addEventListener(tipo, manejador, opciones);
  • tipo: el nombre del evento como cadena, sin el prefijo on: 'click', no 'onclick'.
  • manejador: la función que se ejecutará. Recibe un argumento: el objeto Event.
  • opciones: un objeto opcional que veremos en el apartado siguiente.
const boton = document.querySelector('#marcar-hecha');

boton.addEventListener('click', function (evento) {
  console.log('Se ha hecho clic en', evento.currentTarget.textContent);
});

El error de sintaxis más repetido de todo el módulo es este:

boton.addEventListener('click', marcarHecha());   // ✗ ¡MAL!

Los paréntesis llaman a la función inmediatamente y registran como manejador lo que devuelva (normalmente undefined). Lo que quieres es pasar la función, sin llamarla:

boton.addEventListener('click', marcarHecha);     // ✓ pasa la referencia

¿Y si necesitas pasarle argumentos? Envuélvela en una flecha, como aprendiste en 03-06:

boton.addEventListener('click', () => marcarHecha(6));   // ✓

  1. Las opciones: once, passive, capture, signal

El tercer parámetro admite un objeto con estas propiedades:

Opción Tipo Qué hace
once booleano El manejador se ejecuta una sola vez y se retira solo
passive booleano Promete que el manejador no llamará a preventDefault, lo que permite al navegador desplazar sin esperar
capture booleano Registra el manejador en la fase de captura en lugar de la de burbujeo (lección 06-04)
signal AbortSignal Permite retirar el manejador abortando una señal (lección 06-04)

once es la más útil de las cuatro en el día a día, y sustituye a un patrón que antes había que escribir a mano:

// Mostrar la ayuda solo la primera vez que se abre el formulario
formulario.addEventListener('focusin', mostrarAyuda, { once: true });

// Antes de 'once' había que hacer esto:
function mostrarAyudaUnaVez(evento) {
  mostrarAyuda(evento);
  evento.currentTarget.removeEventListener('focusin', mostrarAyudaUnaVez);
}

passive importa en eventos de desplazamiento y táctiles (scroll, touchstart, wheel). Sin ella, el navegador debe esperar a que tu manejador termine para saber si va a cancelar el desplazamiento, lo que produce sensación de lentitud. Prometiendo que no lo harás, el desplazamiento va fluido:

document.addEventListener('scroll', alDesplazar, { passive: true });

En navegadores modernos, touchstart, touchmove y wheel sobre document ya son pasivos por defecto.

capture y signal pertenecen de lleno a la lección siguiente; se mencionan aquí para que la tabla esté completa.

  1. removeEventListener y el problema de la referencia

Para retirar un manejador se usa removeEventListener con exactamente los mismos tres datos con que se registró: el mismo tipo, la misma función y el mismo valor de capture.

function alHacerClic() { console.log('clic'); }

boton.addEventListener('click', alHacerClic);
boton.removeEventListener('click', alHacerClic);   // ✓ retirado

Y aquí está la trampa que hace perder horas:

boton.addEventListener('click', () => console.log('clic'));
boton.removeEventListener('click', () => console.log('clic'));
// ✗ NO retira nada. Y no da ningún error.

Las dos flechas tienen el mismo código, pero son dos objetos función distintos, exactamente como {} !== {} en 01-07. removeEventListener compara por identidad, no encuentra coincidencia y no hace nada, en silencio.

La misma trampa aparece con bind, que devuelve una función nueva cada vez (04-02):

boton.addEventListener('click', this.alHacerClic.bind(this));
boton.removeEventListener('click', this.alHacerClic.bind(this));   // ✗ otra función distinta

// ✓ Guarda la referencia una sola vez
this.alHacerClicEnlazado = this.alHacerClic.bind(this);
boton.addEventListener('click', this.alHacerClicEnlazado);
boton.removeEventListener('click', this.alHacerClicEnlazado);

Cuándo hay que retirar un manejador. Si el elemento se elimina del DOM y nadie más guarda una referencia a él, el navegador recoge el elemento y sus manejadores: no hace falta hacer nada. Pero si registraste el manejador en window, en document o en un elemento que sigue vivo, y ya no lo necesitas, retíralo; en caso contrario, tu función y todo lo que su closure captura (03-04) quedan retenidos en memoria. Es una de las fugas de memoria clásicas, y tiene lección propia: Gestión de Memoria.

  1. El objeto Event

Cada vez que se dispara un evento, el navegador construye un objeto con toda la información y se lo pasa como primer argumento a tu manejador. Estas son sus propiedades esenciales:

Propiedad Qué contiene
type El tipo de evento: 'click', 'keydown', 'submit'
target El elemento donde se originó el evento (el más profundo)
currentTarget El elemento al que registraste el manejador
timeStamp Milisegundos desde que se creó el documento
isTrusted true si lo generó el usuario; false si lo disparó tu código
defaultPrevented true si alguien ya llamó a preventDefault()

La distinción entre target y currentTarget es la más importante de la tabla, y se ve mejor con un ejemplo. Este <li> contiene un <button>, y el manejador está en el <li>:

<li class="tarea" data-id="6">
  <span class="tarea__titulo">Presupuesto de la carpintería</span>
  <button class="tarea__accion">Marcar hecha</button>
</li>
const li = document.querySelector('[data-id="6"]');

li.addEventListener('click', (evento) => {
  console.log('target:       ', evento.target.tagName);
  console.log('currentTarget:', evento.currentTarget.tagName);
});

// Si haces clic sobre el BOTÓN:
// target:        BUTTON   ← donde ocurrió de verdad
// currentTarget: LI       ← donde escuchas tú

// Si haces clic sobre el TÍTULO:
// target:        SPAN
// currentTarget: LI

Que un clic en el botón active el manejador del <li> tiene una explicación —el evento burbujea hacia arriba— que es el tema central de la lección siguiente. Por ahora quédate con la regla: currentTarget es tu elemento; target es donde el usuario hizo clic realmente.

Un aviso: currentTarget solo tiene valor durante la ejecución del manejador. Si guardas el evento y lo consultas más tarde (por ejemplo dentro de un setTimeout), valdrá null.

boton.addEventListener('click', (evento) => {
  const elemento = evento.currentTarget;         // ✓ guárdalo ahora
  setTimeout(() => {
    console.log(evento.currentTarget);           // null ✗
    console.log(elemento.textContent);           // ✓ funciona
  }, 100);
});

Además, cada tipo de evento aporta sus propias propiedades: un MouseEvent trae clientX, clientY y button; un KeyboardEvent trae key, code, ctrlKey y shiftKey; un InputEvent trae data. Todos heredan de Event, en una jerarquía de prototipos que es la de 05-01 aplicada a las APIs del navegador.

  1. preventDefault y las acciones por defecto

Muchos elementos tienen un comportamiento propio del navegador: un enlace navega, un formulario se envía y recarga la página, la barra espaciadora desplaza el documento, el botón derecho abre el menú contextual. preventDefault() cancela ese comportamiento.

const enlace = document.querySelector('#ayuda');
enlace.addEventListener('click', (evento) => {
  evento.preventDefault();          // ✓ el navegador NO sigue el enlace
  mostrarPanelDeAyuda();
});

El caso que más usarás está en 06-07: interceptar el envío de un formulario para procesarlo con JavaScript en lugar de recargar la página.

formulario.addEventListener('submit', (evento) => {
  evento.preventDefault();          // sin esto, la página se recarga y pierdes todo
  // … crear la tarea con los datos del formulario
});

Dos límites importantes:

  • No todos los eventos son cancelables. Si evento.cancelable es false, preventDefault() no hace nada. Los eventos personalizados solo son cancelables si los creas con { cancelable: true }.
  • Si registraste el manejador con { passive: true }, preventDefault() se ignora y el navegador muestra un aviso en la consola. Es lógico: has prometido que no lo llamarías.

Y no confundas preventDefault() con stopPropagation(): el primero cancela lo que haría el navegador, el segundo detiene el recorrido del evento por el árbol. Son cosas completamente distintas, y la tabla que las separa está en la lección 06-04.

  1. Tabla de referencia de eventos

Estos son los eventos que cubren la práctica totalidad de una aplicación como Nómada Tareas:

Categoría Evento Cuándo se dispara Notas
Ratón click Pulsar y soltar sobre el elemento También lo dispara Enter/Espacio sobre un <button>
dblclick Dos clics rápidos Llega después de dos click
mousedown / mouseup Pulsar / soltar el botón Anteriores al click
mouseenter / mouseleave El puntero entra/sale del elemento No se disparan al pasar a un hijo
mouseover / mouseout El puntero entra/sale, hijos incluidos Se disparan muchas más veces
contextmenu Botón derecho Cancelable
Teclado keydown Se pulsa una tecla Se repite si se mantiene
keyup Se suelta una tecla Una sola vez
Foco focus / blur El elemento gana/pierde el foco No burbujean (ver 06-04)
focusin / focusout Lo mismo, pero sí burbujean Útiles con delegación
Formulario input El valor cambia, en cada pulsación Ideal para validar al vuelo
change El valor cambia y se confirma En texto, al perder el foco; en select, al elegir
submit Se envía el formulario Se dispara en el <form>, no en el botón
reset Se reinicia el formulario Cancelable
Ventana DOMContentLoaded El DOM está construido Sobre document
load / resize / scroll Todo cargado / cambio de tamaño / desplazamiento Sobre window

La diferencia entre input y change merece una demostración, porque se confunden a diario:

const campo = document.querySelector('#titulo');

campo.addEventListener('input',  (e) => console.log('input:',  e.target.value));
campo.addEventListener('change', (e) => console.log('change:', e.target.value));

// Marta escribe "Web" y hace clic fuera:
// input:  W
// input:  We
// input:  Web
// change: Web        ← una sola vez, al confirmar

Y la de mouseenter frente a mouseover:

li.addEventListener('mouseenter', () => console.log('enter'));  // 1 vez al entrar en el <li>
li.addEventListener('mouseover',  () => console.log('over'));   // otra vez al pasar al <span>,
                                                                // otra al pasar al <button>…

Para efectos de «el ratón está sobre esta tarjeta», usa siempre mouseenter/mouseleave.

  1. Eventos de teclado y atajos

El objeto KeyboardEvent trae dos propiedades parecidas que no son lo mismo:

  • event.key: el carácter producido. Depende de la distribución del teclado y de si hay mayúsculas: 'a', 'A', 'Enter', 'Escape', 'ArrowDown', ' ' (espacio).
  • event.code: la tecla física pulsada, independiente de la distribución: 'KeyA', 'Enter', 'Space'.

Para atajos y comandos usa key; code solo tiene sentido en videojuegos, donde importa la posición física de la tecla.

document.addEventListener('keydown', (evento) => {
  // Escape cierra el panel de filtros
  if (evento.key === 'Escape') {
    cerrarFiltros();
    return;
  }

  // Ctrl+K (o Cmd+K en Mac) enfoca el campo de búsqueda
  if (evento.key === 'k' && (evento.ctrlKey || evento.metaKey)) {
    evento.preventDefault();          // el navegador tiene su propio Ctrl+K
    document.querySelector('#buscar').focus();
  }
});

Los modificadores disponibles son ctrlKey, shiftKey, altKey y metaKey (la tecla Command en macOS y Windows en PC). Comprobar ctrlKey || metaKey es la forma habitual de soportar los dos sistemas.

Un detalle de accesibilidad muy importante: un atajo global no debe activarse mientras el usuario escribe en un campo. Si Iván está tecleando el título de una tarea y pulsa la k, no quiere que se abra la búsqueda:

document.addEventListener('keydown', (evento) => {
  const escribiendo = evento.target.matches('input, textarea, select, [contenteditable]');
  if (escribiendo) return;           // guarda: los atajos no aplican dentro de un campo
  // … atajos
});

  1. this dentro de un manejador

Aquí se aplica directamente lo que aprendiste en 04-02. Cuando registras una función tradicional como manejador, el navegador la invoca con this apuntando al elemento donde registraste el manejador, es decir, lo mismo que evento.currentTarget:

boton.addEventListener('click', function (evento) {
  console.log(this === evento.currentTarget);   // true
  this.disabled = true;
});

Con una función flecha, this no se reasigna: la flecha no tiene this propio y toma el del ámbito donde se escribió (03-02). En un módulo de nivel superior, ese this es undefined:

boton.addEventListener('click', (evento) => {
  console.log(this);                  // undefined en un módulo
  console.log(evento.currentTarget);  // ✓ el botón, siempre
});

Esto no es un defecto de las flechas: es exactamente lo que las hace útiles dentro de una clase, donde la pérdida de this en callbacks era el problema de 04-02.

class ControladorTablero {
  constructor(tablero, contenedor) {
    this.tablero = tablero;
    this.contenedor = contenedor;
  }

  conectar() {
    // ✗ Con función tradicional, 'this' pasa a ser el botón y this.tablero es undefined
    // boton.addEventListener('click', function () { this.tablero.resumen(); });

    // ✓ Con flecha, 'this' sigue siendo el controlador
    this.contenedor.addEventListener('click', (evento) => {
      console.log(this.tablero.total);          // 6 ✓
      console.log(evento.currentTarget.id);     // 'lista-tareas' ✓
    });
  }
}

Criterio práctico: usa flechas y evento.currentTarget. Así this significa siempre lo mismo (tu objeto) y el elemento lo obtienes del evento, que nunca miente. Reserva las funciones tradicionales para el caso concreto en que quieras el this del elemento y no estés dentro de una clase.

  1. Accesibilidad: por qué un <div> con click no basta

Esto funciona con el ratón:

<!-- ✗ Un control roto -->
<div class="boton" onclick="marcarHecha(6)">Marcar hecha</div>

Y está roto para todo lo demás. Un <div>:

  • No recibe el foco con el tabulador. Quien navega con teclado no puede llegar hasta él, y por tanto no puede activarlo jamás.
  • No responde a Enter ni a Espacio, que es como se activan los controles con el teclado.
  • No tiene rol. Un lector de pantalla anuncia «Marcar hecha», sin decir que es un botón ni que se puede activar.
  • No tiene estado: no puede estar disabled de forma que las tecnologías de asistencia lo entiendan.

Un <button> trae todo eso de fábrica:

<!-- ✓ Un control correcto -->
<button type="button" class="tarea__accion" data-accion="hecha">Marcar hecha</button>
boton.addEventListener('click', marcarHecha);
// Este manejador se dispara con el ratón, con Enter y con Espacio. Gratis.

Comparación directa:

Necesidad <div> con click <button>
Recibe el foco con Tab No (hace falta tabindex="0")
Se activa con Enter/Espacio No (hay que programarlo)
Se anuncia como botón No (hace falta role="button")
Admite disabled No
Estilo controlable con CSS

El único argumento a favor del <div> era el estilo, y hace muchos años que un <button> se puede estilar por completo (all: unset, appearance: none, o simplemente reescribiendo background, border y padding).

Dos reglas más para el resto del módulo:

  • Cada elemento interactivo usa la etiqueta que le corresponde: <button> para acciones, <a href> para navegar, <input>/<select> para introducir datos. Si un clic te lleva a otra página, es un enlace; si ejecuta una acción en esta, es un botón.
  • Si el botón solo tiene un icono, necesita nombre accesible: aria-label="Marcar como hecha", o un texto visualmente oculto. Un botón sin texto es un botón sin nombre.
<button type="button" class="tarea__accion" data-accion="hecha"
        aria-label="Marcar «Presupuesto de la carpintería» como hecha">✓</button>

  1. Nómada Tareas: marcar como hecha y filtrar por responsable

Vamos a hacer la página interactiva. Ampliamos el HTML con una barra de filtros y un botón por tarea:

<section class="tablero" aria-labelledby="titulo-tablero">
  <h2 id="titulo-tablero">Backlog</h2>

  <div class="filtros" role="group" aria-label="Filtrar por responsable">
    <button type="button" class="filtro" data-responsable="">Todas</button>
    <button type="button" class="filtro" data-responsable="Marta">Marta</button>
    <button type="button" class="filtro" data-responsable="Iván">Iván</button>
    <button type="button" class="filtro" data-responsable="Lucía">Lucía</button>
  </div>

  <p id="resumen" class="resumen" role="status"></p>

  <ul id="lista-tareas" class="lista-tareas">
    <li class="tarea" data-id="1">
      <span class="tarea__titulo"></span>
      <span class="tarea__meta"></span>
      <button type="button" class="tarea__accion" data-accion="avanzar"></button>
    </li>
    <li class="tarea" data-id="3">
      <span class="tarea__titulo"></span>
      <span class="tarea__meta"></span>
      <button type="button" class="tarea__accion" data-accion="avanzar"></button>
    </li>
    <li class="tarea" data-id="6">
      <span class="tarea__titulo"></span>
      <span class="tarea__meta"></span>
      <button type="button" class="tarea__accion" data-accion="avanzar"></button>
    </li>
  </ul>
</section>

El CSS necesario, breve:

.filtros { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.filtro, .tarea__accion {
  font: inherit; padding: 0.3rem 0.7rem; cursor: pointer;
  border: 1px solid var(--borde); border-radius: 0.35rem; background: #fff;
}
.filtro[aria-pressed="true"] { background: #1f2933; color: #fff; }
.tarea__accion[disabled] { opacity: 0.45; cursor: not-allowed; }
.tarea[hidden] { display: none; }

Y el controlador. La clave del apartado 12 es que el modelo decide y la vista obedece: el manejador llama a tablero.cambiarEstado(...), que aplica la regla R6 de transiciones, y después repinta.

// js/vista/controlador.js
import { HOY } from '../util/fechas.js';
import { pintarTarea, pintarResumen } from './pintar.js';

/** Siguiente estado según R6; null si la tarea ya está terminada. */
const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });

const ETIQUETA = Object.freeze({
  pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Completada'
});

/** Refresca el botón de acción de un <li> según el estado de su tarea. */
function refrescarBoton(li, tarea) {
  const boton = li.querySelector('.tarea__accion');
  const siguiente = SIGUIENTE[tarea.estado];
  boton.textContent = ETIQUETA[tarea.estado];
  boton.disabled = siguiente === null;              // ✓ propiedad, no setAttribute
  boton.setAttribute('aria-label', `${ETIQUETA[tarea.estado]}: ${tarea.titulo}`);
}

export function conectarAcciones(contenedor, tablero, hoy = HOY) {
  for (const li of contenedor.querySelectorAll('li[data-id]')) {
    const tarea = tablero.buscarPorId(Number(li.dataset.id));
    if (tarea === null) continue;

    pintarTarea(li, tarea, hoy);
    refrescarBoton(li, tarea);

    const boton = li.querySelector('.tarea__accion');
    boton.addEventListener('click', (evento) => {
      const siguiente = SIGUIENTE[tarea.estado];
      if (siguiente === null) return;

      try {
        tablero.cambiarEstado(tarea.id, siguiente);   // el modelo valida (R6)
      } catch (error) {
        console.error(error.describir?.() ?? error.message);
        return;
      }

      pintarTarea(li, tarea, hoy);
      refrescarBoton(li, tarea);
      pintarResumen(document.querySelector('#resumen'), tablero, hoy);
      console.log(`${tarea.titulo} → ${tarea.estado}`, evento.timeStamp.toFixed(0), 'ms');
    });
  }
}

export function conectarFiltros(barra, contenedor) {
  for (const boton of barra.querySelectorAll('.filtro')) {
    boton.setAttribute('aria-pressed', boton.dataset.responsable === '' ? 'true' : 'false');

    boton.addEventListener('click', (evento) => {
      const elegido = evento.currentTarget.dataset.responsable;

      // Estado visual de los botones (aria-pressed comunica cuál está activo)
      for (const otro of barra.querySelectorAll('.filtro')) {
        otro.setAttribute('aria-pressed', String(otro === evento.currentTarget));
      }

      // Ocultar o mostrar cada tarea. 'hidden' la saca del árbol de accesibilidad.
      for (const li of contenedor.querySelectorAll('li[data-id]')) {
        li.hidden = elegido !== '' && li.dataset.responsable !== elegido;
      }
    });
  }
}
// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY } from './util/fechas.js';
import { pintarResumen } from './vista/pintar.js';
import { conectarAcciones, conectarFiltros } from './vista/controlador.js';

const tablero = new Tablero('Taller Nómada', crearBacklog());

const lista = document.querySelector('#lista-tareas');
const barra = document.querySelector('.filtros');
const resumen = document.querySelector('#resumen');

conectarAcciones(lista, tablero, HOY);
conectarFiltros(barra, lista);
pintarResumen(resumen, tablero, HOY);

Pruébalo: al hacer clic en «Empezar» sobre la carpintería, pasa a en-curso, el botón cambia a «Marcar hecha», y el resumen actualiza las horas abiertas. Al segundo clic pasa a hecha, el título se tacha, el botón se desactiva y las 45 h abiertas bajan a 40. Todo con la regla R6 aplicada por el modelo, no por la vista.

Y ahora la pega. Este código registra un manejador por cada botón. Con tres tareas son tres; con el backlog completo de seis son seis; con doscientas, doscientos. Peor aún: en la lección 06-05 empezarás a crear los <li> desde JavaScript, y esos elementos nuevos no tendrán manejador, porque conectarAcciones ya se ejecutó. Habría que volver a conectar tras cada cambio, con el riesgo de duplicar manejadores.

Existe una solución mucho mejor: un solo manejador en el <ul> que atienda los clics de todos los botones, presentes y futuros. Se llama delegación, se apoya en el burbujeo y en el closest() de 06-02, y es lo primero que verás en la lección siguiente.

Errores Comunes y Consejos

  • Llamar a la función al registrarla: addEventListener('click', hacer()) registra el valor devuelto, no la función. Sin paréntesis, o envuelta en una flecha si necesitas argumentos.
  • Escribir el tipo con on: addEventListener('onclick', …) no da error y no funciona nunca, porque no existe ningún evento llamado onclick. El tipo es 'click'.
  • Intentar retirar una flecha anónima. removeEventListener compara por identidad: si no guardaste la referencia, no puedes retirarla. Guarda la función en una constante, o usa { once: true } o AbortController (06-04).
  • Confundir target con currentTarget. currentTarget es tu elemento; target es donde ocurrió el clic, que puede ser un hijo. Si el botón contiene un <span> con el icono, target será el <span>.
  • Guardar el evento para usarlo más tarde. currentTarget vale null fuera del manejador. Extrae lo que necesites a variables antes de cualquier setTimeout o await.
  • Olvidar preventDefault() en un submit. La página se recarga, el estado se pierde y parece que «no funciona nada». Es el error número uno de la lección 06-07.
  • Confundir input con change. input se dispara en cada pulsación; change solo al confirmar. Para validar al vuelo quieres input; para reaccionar a un <select>, cualquiera de los dos.
  • Usar <div> como botón. Rompe el teclado y los lectores de pantalla. <button type="button"> siempre; y con type explícito, porque dentro de un formulario el valor por defecto es submit.
  • Consejo: nombra tus manejadores. alHacerClicEnAvanzar en una función con nombre aparece en las trazas de la pila y en el panel de Event Listeners de las DevTools; una flecha anónima aparece como (anonymous).
  • Consejo: inspecciona los manejadores registrados. En el panel Elementos de las DevTools hay una pestaña Event Listeners que muestra todos los manejadores de un elemento y de sus antepasados. Es la forma más rápida de descubrir manejadores duplicados.

Ejercicios

Ejercicio 1 · Un contador de clics con once

Escribe un fragmento que registre dos manejadores sobre el botón de filtro «Todas»: uno normal que cuente los clics y los imprima, y otro con { once: true } que imprima «primer clic» y desaparezca. Comprueba con cuatro clics que el primero se ejecuta cuatro veces y el segundo una. Después añade un tercer manejador que puedas retirar desde la consola, y retíralo.

Ejercicio 2 · Atajos de teclado accesibles

Añade a la página estos atajos globales, respetando la regla de no activarlos mientras se escribe en un campo:

  • 1, 2, 3 filtran por Marta, Iván y Lucía respectivamente.
  • 0 quita el filtro.
  • Escape quita el filtro y devuelve el foco al primer botón de la barra.

Reutiliza los botones existentes en lugar de duplicar la lógica de filtrado.

Ejercicio 3 · Vista previa al pasar el ratón

Cuando el puntero entre en un <li> de tarea, muestra en el párrafo de resumen una línea con el título, el revisor y los días que faltan hasta la fecha límite; al salir, restaura el resumen general. Usa los eventos correctos para que el mensaje no parpadee al pasar el ratón por encima del botón interior, y explica por qué esa pareja de eventos es la adecuada.

Soluciones

Ejercicio 1

const botonTodas = document.querySelector('.filtro[data-responsable=""]');

let clics = 0;
botonTodas.addEventListener('click', () => {
  clics += 1;
  console.log('clics:', clics);
});

botonTodas.addEventListener('click', () => console.log('primer clic'), { once: true });

// Tercer manejador, retirable: hay que guardar la referencia
function avisar(evento) {
  console.log('avisando desde', evento.currentTarget.textContent);
}
botonTodas.addEventListener('click', avisar);

// Más tarde, desde la consola:
botonTodas.removeEventListener('click', avisar);   // ✓ funciona: misma referencia

Con cuatro clics la consola muestra clics: 1, primer clic, clics: 2, clics: 3, clics: 4. El orden dentro de un mismo clic es el de registro. El contador funciona porque clics vive en el closure del manejador (03-04): cada llamada ve y actualiza la misma variable.

Ejercicio 2

const barra = document.querySelector('.filtros');
const POR_TECLA = { '1': 'Marta', '2': 'Iván', '3': 'Lucía', '0': '' };

function pulsarFiltro(responsable) {
  barra.querySelector(`.filtro[data-responsable="${responsable}"]`)?.click();
}

document.addEventListener('keydown', (evento) => {
  // Guarda: los atajos no aplican mientras se escribe
  if (evento.target.matches('input, textarea, select, [contenteditable]')) return;
  if (evento.ctrlKey || evento.metaKey || evento.altKey) return;   // no pisar atajos del sistema

  if (Object.hasOwn(POR_TECLA, evento.key)) {
    evento.preventDefault();
    pulsarFiltro(POR_TECLA[evento.key]);
    return;
  }

  if (evento.key === 'Escape') {
    pulsarFiltro('');
    barra.querySelector('.filtro').focus();
  }
});

Lo elegante de esta solución es boton.click(): en lugar de duplicar la lógica de filtrado, simula el clic sobre el botón que ya la tiene, y así el estado visual (aria-pressed) queda coherente sin escribir una línea más. El evento que genera click() tiene isTrusted: false, pero por lo demás es idéntico. El objeto POR_TECLA es el diccionario de consulta de 02-03, mucho más limpio que una cadena de if. Y devolver el foco tras Escape es una cortesía imprescindible: sin ella, quien navega con teclado se queda sin punto de partida.

Ejercicio 3

import { fechaLegible } from './util/fechas.js';

const resumen = document.querySelector('#resumen');

for (const li of lista.querySelectorAll('li[data-id]')) {
  const tarea = tablero.buscarPorId(Number(li.dataset.id));

  li.addEventListener('mouseenter', () => {
    resumen.textContent =
      `${tarea.titulo} · revisa ${tarea.revisor ?? 'nadie'} · ` +
      `límite ${fechaLegible(tarea.fechaLimite)} (${tarea.diasRestantes} días)`;
  });

  li.addEventListener('mouseleave', () => {
    pintarResumen(resumen, tablero, HOY);
  });
}

La pareja correcta es mouseenter/mouseleave porque no se disparan al moverse entre los hijos del elemento. Con mouseover/mouseout el mensaje parpadearía: al pasar del <span> del título al <button>, saltaría un mouseout (que restauraría el resumen) seguido de un mouseover (que lo volvería a poner), decenas de veces por segundo. mouseenter solo se dispara al cruzar la frontera exterior del <li>.

Un apunte de accesibilidad: esta vista previa solo existe para quien usa ratón. Para hacerla completa habría que añadir los equivalentes de teclado (focusin/focusout sobre un elemento enfocable), y eso encaja con los eventos que sí burbujean de la lección siguiente.

Conclusión

Ya sabes hacer que la página responda. Un evento es una notificación que genera el navegador, un manejador es una función que registras para que la llamen por ti, y el mecanismo que lo hace posible es el mismo bucle de eventos de 05-07: tu manejador se encola como una tarea y se ejecuta cuando la pila queda libre, de donde se sigue la regla de que un manejador debe ser breve, porque mientras corre la interfaz está congelada.

De las tres formas históricas de registrar manejadores —el atributo onclick, la propiedad elemento.onclick y addEventListener— solo la última es aceptable: separa marcado y lógica, admite varios manejadores por evento, funciona con módulos y acepta opciones. Conoces esas opciones: once para lo que solo ocurre una vez, passive para no bloquear el desplazamiento, y capture y signal que se despliegan en la lección siguiente. Y conoces la trampa de removeEventListener: compara por identidad, así que una flecha anónima o un bind en línea son imposibles de retirar; hay que guardar la referencia, y hay que retirar los manejadores de window y document que ya no se usen para no retener memoria.

Del objeto Event dominas lo esencial: type, timeStamp, isTrusted, la diferencia crucial entre target (donde ocurrió) y currentTarget (donde escuchas) —con el aviso de que este último se vacía al salir del manejador—, y preventDefault() para cancelar la acción por defecto del navegador, que será imprescindible en el submit del formulario. Tienes la tabla de referencia de eventos de ratón, teclado, foco y formulario, con las distinciones que más se confunden: input frente a change, mouseenter frente a mouseover, key frente a code. Sabes que this en un manejador tradicional es el elemento y en una flecha es el del ámbito exterior, y que el criterio sensato dentro de una clase es usar flechas y leer el elemento de evento.currentTarget. Y tienes claro, sin excusas, que un control se escribe con <button>: el <div> con click no recibe foco, no responde a Enter ni a Espacio y no se anuncia como botón.

En Nómada Tareas eso ha dado js/vista/controlador.js con conectarAcciones y conectarFiltros: cada tarea avanza pendiente → en-curso → hecha con la regla R6 validada por el modelo, el botón cambia de texto y se desactiva al terminar, el resumen se actualiza, y la barra de filtros usa aria-pressed y hidden para comunicar el estado a todo el mundo.

Y ha aparecido un problema que no se puede ignorar: hay un manejador por botón. Seis tareas, seis manejadores; y en cuanto empieces a crear los <li> desde JavaScript, los elementos nuevos nacerán sin manejador. La solución no es reconectar cada vez, sino entender cómo viaja un evento por el árbol —captura, objetivo y burbujeo— y colocar un único manejador en el <ul> que atienda a todos los botones que existan ahora y en el futuro. Ese patrón, junto con las herramientas para detener el recorrido de un evento y con los eventos personalizados que te permitirán desacoplar la vista del modelo, es Propagación, Delegación y Eventos Personalizados.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados