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
- Qué es un evento y qué es la programación dirigida por eventos
- Las tres formas de registrar un manejador
addEventListenera fondo- Las opciones:
once,passive,capture,signal removeEventListenery el problema de la referencia- El objeto
Event preventDefaulty las acciones por defecto- Tabla de referencia de eventos
- Eventos de teclado y atajos
thisdentro de un manejador- Accesibilidad: por qué un
<div>conclickno basta - Nómada Tareas: marcar como hecha y filtrar por responsable
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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).
- 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.
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 |
Sí | No | No | Sí | Solo en código muy antiguo |
addEventListener |
Sí | Sí | Sí | Sí | Siempre |
addEventListener a fondo
addEventListener a fondoLa firma es sencilla y siempre la misma:
tipo: el nombre del evento como cadena, sin el prefijoon:'click', no'onclick'.manejador: la función que se ejecutará. Recibe un argumento: el objetoEvent.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:
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:
¿Y si necesitas pasarle argumentos? Envuélvela en una flecha, como aprendiste en 03-06:
- Las opciones:
once, passive, capture, signal
once, passive, capture, signalEl 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:
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.
removeEventListener y el problema de la referencia
removeEventListener y el problema de la referenciaPara 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); // ✓ retiradoY 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.
- El objeto
Event
EventCada 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: LIQue 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.
preventDefault y las acciones por defecto
preventDefault y las acciones por defectoMuchos 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.cancelableesfalse,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.
- 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 confirmarY 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.
- 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
});
this dentro de un manejador
this dentro de un manejadorAquí 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.
- Accesibilidad: por qué un
<div> con click no basta
<div> con click no bastaEsto funciona con el ratón:
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
disabledde 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") |
Sí |
| Se activa con Enter/Espacio | No (hay que programarlo) | Sí |
| Se anuncia como botón | No (hace falta role="button") |
Sí |
Admite disabled |
No | Sí |
| Estilo controlable con CSS | Sí | Sí |
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>
- 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 llamadoonclick. El tipo es'click'. - Intentar retirar una flecha anónima.
removeEventListenercompara por identidad: si no guardaste la referencia, no puedes retirarla. Guarda la función en una constante, o usa{ once: true }oAbortController(06-04). - Confundir
targetconcurrentTarget.currentTargetes tu elemento;targetes donde ocurrió el clic, que puede ser un hijo. Si el botón contiene un<span>con el icono,targetserá el<span>. - Guardar el evento para usarlo más tarde.
currentTargetvalenullfuera del manejador. Extrae lo que necesites a variables antes de cualquiersetTimeoutoawait. - Olvidar
preventDefault()en unsubmit. 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
inputconchange.inputse dispara en cada pulsación;changesolo al confirmar. Para validar al vuelo quieresinput; 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 contypeexplícito, porque dentro de un formulario el valor por defecto essubmit. - Consejo: nombra tus manejadores.
alHacerClicEnAvanzaren 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,3filtran por Marta, Iván y Lucía respectivamente.0quita el filtro.Escapequita 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 referenciaCon 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
