La lliçó anterior va deixar la pàgina de Nómada Tasques mostrant dades reals, però completament muda: un cartell bonic amb el qual no es pot interactuar. Una aplicació és una altra cosa. És un programa que espera i que reacciona: algú fa clic, escriu en un camp, prem una tecla, i el programa respon. Aquell model de treball s'anomena programació dirigida per esdeveniments, i és la manera natural de programar al navegador. En aquesta lliçó aprendràs a registrar gestors amb addEventListener, a llegir la informació que porta l'objecte Event, a retirar gestors quan ja no calen, a distingir els esdeveniments que faràs servir més, i a evitar el parany més habitual de tots: construir controls amb <div> que deixen fora qui no fa servir el ratolí. En acabar, la Marta podrà marcar una tasca com a feta amb un clic i la Lucía podrà veure només el que és seu.

Contingut

  1. Què és un esdeveniment i què és la programació dirigida per esdeveniments
  2. Les tres maneres de registrar un gestor
  3. addEventListener a fons
  4. Les opcions: once, passive, capture, signal
  5. removeEventListener i el problema de la referència
  6. L'objecte Event
  7. preventDefault i les accions per defecte
  8. Taula de referència d'esdeveniments
  9. Esdeveniments de teclat i dreceres
  10. this dins d'un gestor
  11. Accessibilitat: per què un <div> amb click no n'hi ha prou
  12. Nómada Tasques: marcar com a feta i filtrar per responsable
  13. Errors Habituals i Consells
  14. Exercicis
  15. Conclusió

  1. Què és un esdeveniment i què és la programació dirigida per esdeveniments

Un esdeveniment és una notificació que ha passat alguna cosa. El genera el navegador i pot venir d'una persona (un clic, una tecla, un desplaçament), del mateix navegador (la pàgina ha acabat de carregar, una imatge ha fallat) o del teu codi (els esdeveniments personalitzats de 06-04).

Un gestor (o listener) és una funció que tu registres perquè s'executi quan passi un esdeveniment concret sobre un element concret. I aquí hi ha la clau conceptual: tu no crides mai aquella funció. La registres i te n'oblides. És el navegador qui la crida.

Això encaixa exactament amb el bucle d'esdeveniments de 05-07. Quan registres un gestor de click sobre un botó, no passa res; el fil principal queda lliure. Quan algú fa clic, el navegador encua la teva funció com una macrotasca i el bucle d'esdeveniments l'executarà tan bon punt la pila de crides estigui buida.

flowchart LR
    A["La Marta fa clic<br/>en un botó"] --> B["El navegador crea<br/>un objecte Event"]
    B --> C["Encua el gestor<br/>a la cua de tasques"]
    C --> D{"Pila de crides<br/>buida?"}
    D -- "No" --> D
    D -- "Sí" --> E["El bucle d'esdeveniments<br/>executa la teva funció"]
    E --> F["La funció modifica<br/>el model i el DOM"]

D'aquí en surt una conseqüència pràctica que ja coneixes del Mòdul 5 però que ara es torna tangible: si el teu gestor triga, la interfície es congela. Mentre la teva funció s'executa, el fil està ocupat, i el navegador no pot ni repintar ni atendre altres clics. Un gestor ha de fer poca feina i fer-la de pressa; si necessita alguna cosa cara, es reparteix o s'ajorna (Mòdul 9).

  1. Les tres maneres de registrar un gestor

Històricament hi ha hagut tres maneres de connectar una funció a un esdeveniment. Només una és acceptable avui, però convé reconèixer les altres dues perquè són a tot arreu.

Manera 1: l'atribut HTML onclick.

<!-- ✗ No facis això -->
<button onclick="marcarFeta(6)">Marcar feta</button>

El navegador pren aquella cadena i l'avalua com a codi. Barreja el marcatge amb el comportament, obliga que marcarFeta sigui una funció global (cosa que xoca amb els mòduls de 05-04, l'àmbit dels quals no és global), és impossible de mantenir i les polítiques de seguretat de contingut modernes ho bloquegen directament.

Manera 2: la propietat element.onclick.

// ✗ Millor que l'anterior, però continua sent limitada
const boto = document.querySelector('#marcar-feta');
boto.onclick = () => console.log('clic');
boto.onclick = () => console.log('un altre clic');   // ← trepitja l'anterior!
// Només s'executa el segon.

És a JavaScript, cosa que ja és una millora, però només admet un gestor per esdeveniment i element. Si el teu codi en posa un i una biblioteca en posa un altre, un dels dos desapareix silenciosament.

Manera 3: addEventListener.

// ✓ L'única manera correcta
const boto = document.querySelector('#marcar-feta');
boto.addEventListener('click', () => console.log('clic'));
boto.addEventListener('click', () => console.log('un altre clic'));
// S'executen TOTS DOS, en l'ordre en què s'han registrat.
Manera Separa marcatge i lògica Diversos gestors Opcions (once, capture…) Funciona amb mòduls Veredicte
Atribut onclick="…" No No No No (exigeix funció global) Mai
Propietat elem.onclick No No Només en codi molt antic
addEventListener Sempre

  1. addEventListener a fons

La signatura és senzilla i sempre la mateixa:

element.addEventListener(tipus, gestor, opcions);
  • tipus: el nom de l'esdeveniment com a cadena, sense el prefix on: 'click', no 'onclick'.
  • gestor: la funció que s'executarà. Rep un argument: l'objecte Event.
  • opcions: un objecte opcional que veurem a l'apartat següent.
const boto = document.querySelector('#marcar-feta');

boto.addEventListener('click', function (esdeveniment) {
  console.log('Han fet clic a', esdeveniment.currentTarget.textContent);
});

L'error de sintaxi més repetit de tot el mòdul és aquest:

boto.addEventListener('click', marcarFeta());   // ✗ MALAMENT!

Els parèntesis criden la funció immediatament i registren com a gestor el que retorni (normalment undefined). El que vols és passar la funció, sense cridar-la:

boto.addEventListener('click', marcarFeta);     // ✓ passa la referència

I si li has de passar arguments? Embolcalla-la en una fletxa, com vas aprendre a 03-06:

boto.addEventListener('click', () => marcarFeta(6));   // ✓

  1. Les opcions: once, passive, capture, signal

El tercer paràmetre admet un objecte amb aquestes propietats:

Opció Tipus Què fa
once booleà El gestor s'executa una sola vegada i es retira sol
passive booleà Promet que el gestor no cridarà preventDefault, cosa que permet al navegador desplaçar sense esperar
capture booleà Registra el gestor a la fase de captura en lloc de la de bombolla (lliçó 06-04)
signal AbortSignal Permet retirar el gestor avortant un senyal (lliçó 06-04)

once és la més útil de les quatre en el dia a dia, i substitueix un patró que abans calia escriure a mà:

// Mostrar l'ajuda només la primera vegada que s'obre el formulari
formulari.addEventListener('focusin', mostrarAjuda, { once: true });

// Abans d'existir 'once' calia fer això:
function mostrarAjudaUnCop(esdeveniment) {
  mostrarAjuda(esdeveniment);
  esdeveniment.currentTarget.removeEventListener('focusin', mostrarAjudaUnCop);
}

passive importa en esdeveniments de desplaçament i tàctils (scroll, touchstart, wheel). Sense ella, el navegador ha d'esperar que el teu gestor acabi per saber si cancel·larà el desplaçament, cosa que produeix sensació de lentitud. Prometent que no ho faràs, el desplaçament va fluid:

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

En navegadors moderns, touchstart, touchmove i wheel sobre document ja són passius per defecte.

capture i signal pertanyen de ple a la lliçó següent; s'esmenten aquí perquè la taula sigui completa.

  1. removeEventListener i el problema de la referència

Per retirar un gestor es fa servir removeEventListener amb exactament les mateixes tres dades amb què es va registrar: el mateix tipus, la mateixa funció i el mateix valor de capture.

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

boto.addEventListener('click', enFerClic);
boto.removeEventListener('click', enFerClic);   // ✓ retirat

I aquí hi ha el parany que fa perdre hores:

boto.addEventListener('click', () => console.log('clic'));
boto.removeEventListener('click', () => console.log('clic'));
// ✗ NO retira res. I no dona cap error.

Les dues fletxes tenen el mateix codi, però són dos objectes funció diferents, exactament com {} !== {} a 01-07. removeEventListener compara per identitat, no troba coincidència i no fa res, en silenci.

El mateix parany apareix amb bind, que retorna una funció nova cada vegada (04-02):

boto.addEventListener('click', this.enFerClic.bind(this));
boto.removeEventListener('click', this.enFerClic.bind(this));   // ✗ una altra funció diferent

// ✓ Desa la referència una sola vegada
this.enFerClicLligat = this.enFerClic.bind(this);
boto.addEventListener('click', this.enFerClicLligat);
boto.removeEventListener('click', this.enFerClicLligat);

Quan cal retirar un gestor. Si l'element s'elimina del DOM i ningú més no en desa una referència, el navegador recull l'element i els seus gestors: no cal fer res. Però si vas registrar el gestor a window, a document o en un element que continua viu, i ja no el necessites, retira'l; en cas contrari, la teva funció i tot el que el seu closure captura (03-04) queden retinguts en memòria. És una de les fuites de memòria clàssiques, i té lliçó pròpia: Gestió de Memòria.

  1. L'objecte Event

Cada vegada que es dispara un esdeveniment, el navegador construeix un objecte amb tota la informació i el passa com a primer argument al teu gestor. Aquestes són les seves propietats essencials:

Propietat Què conté
type El tipus d'esdeveniment: 'click', 'keydown', 'submit'
target L'element on s'ha originat l'esdeveniment (el més profund)
currentTarget L'element al qual vas registrar el gestor
timeStamp Mil·lisegons des que es va crear el document
isTrusted true si l'ha generat l'usuari; false si l'ha disparat el teu codi
defaultPrevented true si algú ja ha cridat preventDefault()

La distinció entre target i currentTarget és la més important de la taula, i es veu millor amb un exemple. Aquest <li> conté un <button>, i el gestor és al <li>:

<li class="tasca" data-id="6">
  <span class="tasca__titol">Pressupost de la fusteria</span>
  <button class="tasca__accio">Marcar feta</button>
</li>
const li = document.querySelector('[data-id="6"]');

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

// Si fas clic sobre el BOTÓ:
// target:        BUTTON   ← on ha passat de debò
// currentTarget: LI       ← on escoltes tu

// Si fas clic sobre el TÍTOL:
// target:        SPAN
// currentTarget: LI

Que un clic al botó activi el gestor del <li> té una explicació —l'esdeveniment fa bombolla cap amunt— que és el tema central de la lliçó següent. Per ara queda't amb la regla: currentTarget és el teu element; target és on l'usuari ha fet clic realment.

Un avís: currentTarget només té valor durant l'execució del gestor. Si deses l'esdeveniment i el consultes més tard (per exemple dins d'un setTimeout), valdrà null.

boto.addEventListener('click', (esdeveniment) => {
  const element = esdeveniment.currentTarget;    // ✓ desa'l ara
  setTimeout(() => {
    console.log(esdeveniment.currentTarget);     // null ✗
    console.log(element.textContent);            // ✓ funciona
  }, 100);
});

A més, cada tipus d'esdeveniment aporta les seves pròpies propietats: un MouseEvent porta clientX, clientY i button; un KeyboardEvent porta key, code, ctrlKey i shiftKey; un InputEvent porta data. Tots hereten d'Event, en una jerarquia de prototips que és la de 05-01 aplicada a les APIs del navegador.

  1. preventDefault i les accions per defecte

Molts elements tenen un comportament propi del navegador: un enllaç navega, un formulari s'envia i recarrega la pàgina, la barra espaiadora desplaça el document, el botó dret obre el menú contextual. preventDefault() cancel·la aquell comportament.

const enllac = document.querySelector('#ajuda');
enllac.addEventListener('click', (esdeveniment) => {
  esdeveniment.preventDefault();     // ✓ el navegador NO segueix l'enllaç
  mostrarPanellAjuda();
});

El cas que faràs servir més és a 06-07: interceptar l'enviament d'un formulari per processar-lo amb JavaScript en lloc de recarregar la pàgina.

formulari.addEventListener('submit', (esdeveniment) => {
  esdeveniment.preventDefault();     // sense això, la pàgina es recarrega i ho perds tot
  // … crear la tasca amb les dades del formulari
});

Dos límits importants:

  • No tots els esdeveniments són cancel·lables. Si esdeveniment.cancelable és false, preventDefault() no fa res. Els esdeveniments personalitzats només són cancel·lables si els crees amb { cancelable: true }.
  • Si vas registrar el gestor amb { passive: true }, preventDefault() s'ignora i el navegador mostra un avís a la consola. És lògic: has promès que no el cridaries.

I no confonguis preventDefault() amb stopPropagation(): el primer cancel·la el que faria el navegador, el segon atura el recorregut de l'esdeveniment per l'arbre. Són coses completament diferents, i la taula que les separa és a la lliçó 06-04.

  1. Taula de referència d'esdeveniments

Aquests són els esdeveniments que cobreixen la pràctica totalitat d'una aplicació com Nómada Tasques:

Categoria Esdeveniment Quan es dispara Notes
Ratolí click Prémer i deixar anar sobre l'element També el dispara Enter/Espai sobre un <button>
dblclick Dos clics ràpids Arriba després de dos click
mousedown / mouseup Prémer / deixar anar el botó Anteriors al click
mouseenter / mouseleave El punter entra/surt de l'element No es disparen en passar a un fill
mouseover / mouseout El punter entra/surt, fills inclosos Es disparen moltes més vegades
contextmenu Botó dret Cancel·lable
Teclat keydown Es prem una tecla Es repeteix si es manté
keyup Es deixa anar una tecla Una sola vegada
Focus focus / blur L'element guanya/perd el focus No fan bombolla (vegeu 06-04)
focusin / focusout El mateix, però sí que fan bombolla Útils amb delegació
Formulari input El valor canvia, a cada pulsació Ideal per validar al vol
change El valor canvia i es confirma En text, en perdre el focus; en select, en triar
submit S'envia el formulari Es dispara al <form>, no al botó
reset Es reinicia el formulari Cancel·lable
Finestra DOMContentLoaded El DOM està construït Sobre document
load / resize / scroll Tot carregat / canvi de mida / desplaçament Sobre window

La diferència entre input i change mereix una demostració, perquè es confonen a diari:

const camp = document.querySelector('#titol');

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

// La Marta escriu "Web" i fa clic fora:
// input:  W
// input:  We
// input:  Web
// change: Web        ← una sola vegada, en confirmar

I la de mouseenter davant de mouseover:

li.addEventListener('mouseenter', () => console.log('enter'));  // 1 cop en entrar al <li>
li.addEventListener('mouseover',  () => console.log('over'));   // un altre cop en passar al <span>,
                                                                // un altre en passar al <button>…

Per a efectes de «el ratolí és sobre aquesta targeta», fes servir sempre mouseenter/mouseleave.

  1. Esdeveniments de teclat i dreceres

L'objecte KeyboardEvent porta dues propietats semblants que no són el mateix:

  • event.key: el caràcter produït. Depèn de la distribució del teclat i de si hi ha majúscules: 'a', 'A', 'Enter', 'Escape', 'ArrowDown', ' ' (espai).
  • event.code: la tecla física premuda, independent de la distribució: 'KeyA', 'Enter', 'Space'.

Per a dreceres i ordres fes servir key; code només té sentit en videojocs, on importa la posició física de la tecla.

document.addEventListener('keydown', (esdeveniment) => {
  // Escape tanca el panell de filtres
  if (esdeveniment.key === 'Escape') {
    tancarFiltres();
    return;
  }

  // Ctrl+K (o Cmd+K al Mac) enfoca el camp de cerca
  if (esdeveniment.key === 'k' && (esdeveniment.ctrlKey || esdeveniment.metaKey)) {
    esdeveniment.preventDefault();     // el navegador té el seu propi Ctrl+K
    document.querySelector('#cercar').focus();
  }
});

Els modificadors disponibles són ctrlKey, shiftKey, altKey i metaKey (la tecla Command a macOS i Windows al PC). Comprovar ctrlKey || metaKey és la manera habitual de donar suport als dos sistemes.

Un detall d'accessibilitat molt important: una drecera global no s'ha d'activar mentre l'usuari escriu en un camp. Si l'Iván està teclejant el títol d'una tasca i prem la k, no vol que s'obri la cerca:

document.addEventListener('keydown', (esdeveniment) => {
  const escrivint = esdeveniment.target.matches('input, textarea, select, [contenteditable]');
  if (escrivint) return;             // guarda: les dreceres no apliquen dins d'un camp
  // … dreceres
});

  1. this dins d'un gestor

Aquí s'aplica directament el que vas aprendre a 04-02. Quan registres una funció tradicional com a gestor, el navegador la invoca amb this apuntant a l'element on vas registrar el gestor, és a dir, el mateix que esdeveniment.currentTarget:

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

Amb una funció fletxa, this no es reassigna: la fletxa no té this propi i pren el de l'àmbit on es va escriure (03-02). En un mòdul de nivell superior, aquell this és undefined:

boto.addEventListener('click', (esdeveniment) => {
  console.log(this);                        // undefined en un mòdul
  console.log(esdeveniment.currentTarget);  // ✓ el botó, sempre
});

Això no és un defecte de les fletxes: és exactament el que les fa útils dins d'una classe, on la pèrdua de this en callbacks era el problema de 04-02.

class ControladorTauler {
  constructor(tauler, contenidor) {
    this.tauler = tauler;
    this.contenidor = contenidor;
  }

  connectar() {
    // ✗ Amb funció tradicional, 'this' passa a ser el botó i this.tauler és undefined
    // boto.addEventListener('click', function () { this.tauler.resum(); });

    // ✓ Amb fletxa, 'this' continua sent el controlador
    this.contenidor.addEventListener('click', (esdeveniment) => {
      console.log(this.tauler.total);                 // 6 ✓
      console.log(esdeveniment.currentTarget.id);     // 'llista-tasques' ✓
    });
  }
}

Criteri pràctic: fes servir fletxes i esdeveniment.currentTarget. Així this significa sempre el mateix (el teu objecte) i l'element l'obtens de l'esdeveniment, que no menteix mai. Reserva les funcions tradicionals per al cas concret en què vulguis el this de l'element i no siguis dins d'una classe.

  1. Accessibilitat: per què un <div> amb click no n'hi ha prou

Això funciona amb el ratolí:

<!-- ✗ Un control trencat -->
<div class="boto" onclick="marcarFeta(6)">Marcar feta</div>

I està trencat per a tota la resta. Un <div>:

  • No rep el focus amb el tabulador. Qui navega amb teclat no hi pot arribar, i per tant no el pot activar mai.
  • No respon a Enter ni a Espai, que és com s'activen els controls amb el teclat.
  • No té rol. Un lector de pantalla anuncia «Marcar feta», sense dir que és un botó ni que es pot activar.
  • No té estat: no pot estar disabled de manera que les tecnologies d'assistència ho entenguin.

Un <button> porta tot això de fàbrica:

<!-- ✓ Un control correcte -->
<button type="button" class="tasca__accio" data-accio="feta">Marcar feta</button>
boto.addEventListener('click', marcarFeta);
// Aquest gestor es dispara amb el ratolí, amb Enter i amb Espai. De franc.

Comparació directa:

Necessitat <div> amb click <button>
Rep el focus amb Tab No (cal tabindex="0")
S'activa amb Enter/Espai No (s'ha de programar)
S'anuncia com a botó No (cal role="button")
Admet disabled No
Estil controlable amb CSS

L'únic argument a favor del <div> era l'estil, i fa molts anys que un <button> es pot estilar completament (all: unset, appearance: none, o simplement reescrivint background, border i padding).

Dues regles més per a la resta del mòdul:

  • Cada element interactiu fa servir l'etiqueta que li correspon: <button> per a accions, <a href> per navegar, <input>/<select> per introduir dades. Si un clic et porta a una altra pàgina, és un enllaç; si executa una acció en aquesta, és un botó.
  • Si el botó només té una icona, necessita nom accessible: aria-label="Marcar com a feta", o un text visualment ocult. Un botó sense text és un botó sense nom.
<button type="button" class="tasca__accio" data-accio="feta"
        aria-label="Marcar «Pressupost de la fusteria» com a feta">✓</button>

  1. Nómada Tasques: marcar com a feta i filtrar per responsable

Farem la pàgina interactiva. Ampliem l'HTML amb una barra de filtres i un botó per tasca:

<section class="tauler" aria-labelledby="titol-tauler">
  <h2 id="titol-tauler">Backlog</h2>

  <div class="filtres" role="group" aria-label="Filtrar per responsable">
    <button type="button" class="filtre" data-responsable="">Totes</button>
    <button type="button" class="filtre" data-responsable="Marta">Marta</button>
    <button type="button" class="filtre" data-responsable="Iván">Iván</button>
    <button type="button" class="filtre" data-responsable="Lucía">Lucía</button>
  </div>

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

  <ul id="llista-tasques" class="llista-tasques">
    <li class="tasca" data-id="1">
      <span class="tasca__titol"></span>
      <span class="tasca__meta"></span>
      <button type="button" class="tasca__accio" data-accio="avancar"></button>
    </li>
    <li class="tasca" data-id="3">
      <span class="tasca__titol"></span>
      <span class="tasca__meta"></span>
      <button type="button" class="tasca__accio" data-accio="avancar"></button>
    </li>
    <li class="tasca" data-id="6">
      <span class="tasca__titol"></span>
      <span class="tasca__meta"></span>
      <button type="button" class="tasca__accio" data-accio="avancar"></button>
    </li>
  </ul>
</section>

El CSS necessari, breu:

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

I el controlador. La clau de l'apartat 12 és que el model decideix i la vista obeeix: el gestor crida tauler.canviarEstat(...), que aplica la regla R6 de transicions, i després repinta.

// js/vista/controlador.js
import { AVUI } from '../util/dates.js';
import { pintarTasca, pintarResum } from './pintar.js';

/** Estat següent segons R6; null si la tasca ja està acabada. */
const SEGUENT = Object.freeze({ pendent: 'en-curs', 'en-curs': 'feta', feta: null });

const ETIQUETA = Object.freeze({
  pendent: 'Començar', 'en-curs': 'Marcar feta', feta: 'Completada'
});

/** Refresca el botó d'acció d'un <li> segons l'estat de la seva tasca. */
function refrescarBoto(li, tasca) {
  const boto = li.querySelector('.tasca__accio');
  const seguent = SEGUENT[tasca.estat];
  boto.textContent = ETIQUETA[tasca.estat];
  boto.disabled = seguent === null;                 // ✓ propietat, no setAttribute
  boto.setAttribute('aria-label', `${ETIQUETA[tasca.estat]}: ${tasca.titol}`);
}

export function connectarAccions(contenidor, tauler, avui = AVUI) {
  for (const li of contenidor.querySelectorAll('li[data-id]')) {
    const tasca = tauler.cercarPerId(Number(li.dataset.id));
    if (tasca === null) continue;

    pintarTasca(li, tasca, avui);
    refrescarBoto(li, tasca);

    const boto = li.querySelector('.tasca__accio');
    boto.addEventListener('click', (esdeveniment) => {
      const seguent = SEGUENT[tasca.estat];
      if (seguent === null) return;

      try {
        tauler.canviarEstat(tasca.id, seguent);       // el model valida (R6)
      } catch (error) {
        console.error(error.descriure?.() ?? error.message);
        return;
      }

      pintarTasca(li, tasca, avui);
      refrescarBoto(li, tasca);
      pintarResum(document.querySelector('#resum'), tauler, avui);
      console.log(`${tasca.titol} → ${tasca.estat}`, esdeveniment.timeStamp.toFixed(0), 'ms');
    });
  }
}

export function connectarFiltres(barra, contenidor) {
  for (const boto of barra.querySelectorAll('.filtre')) {
    boto.setAttribute('aria-pressed', boto.dataset.responsable === '' ? 'true' : 'false');

    boto.addEventListener('click', (esdeveniment) => {
      const triat = esdeveniment.currentTarget.dataset.responsable;

      // Estat visual dels botons (aria-pressed comunica quin està actiu)
      for (const altre of barra.querySelectorAll('.filtre')) {
        altre.setAttribute('aria-pressed', String(altre === esdeveniment.currentTarget));
      }

      // Amagar o mostrar cada tasca. 'hidden' la treu de l'arbre d'accessibilitat.
      for (const li of contenidor.querySelectorAll('li[data-id]')) {
        li.hidden = triat !== '' && li.dataset.responsable !== triat;
      }
    });
  }
}
// js/app.js
import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';
import { AVUI } from './util/dates.js';
import { pintarResum } from './vista/pintar.js';
import { connectarAccions, connectarFiltres } from './vista/controlador.js';

const tauler = new Tauler('Taller Nómada', crearBacklog());

const llista = document.querySelector('#llista-tasques');
const barra = document.querySelector('.filtres');
const resum = document.querySelector('#resum');

connectarAccions(llista, tauler, AVUI);
connectarFiltres(barra, llista);
pintarResum(resum, tauler, AVUI);

Prova-ho: en fer clic a «Començar» sobre la fusteria, passa a en-curs, el botó canvia a «Marcar feta», i el resum actualitza les hores obertes. Al segon clic passa a feta, el títol es ratlla, el botó es desactiva i les 45 h obertes baixen a 40. Tot amb la regla R6 aplicada pel model, no per la vista.

I ara la pega. Aquest codi registra un gestor per cada botó. Amb tres tasques en són tres; amb el backlog complet de sis, sis; amb dues-centes, dues-centes. Encara pitjor: a la lliçó 06-05 començaràs a crear els <li> des de JavaScript, i aquells elements nous no tindran gestor, perquè connectarAccions ja s'ha executat. Caldria tornar a connectar després de cada canvi, amb el risc de duplicar gestors.

Existeix una solució molt millor: un sol gestor al <ul> que atengui els clics de tots els botons, presents i futurs. S'anomena delegació, es recolza en la bombolla i en el closest() de 06-02, i és el primer que veuràs a la lliçó següent.

Errors Habituals i Consells

  • Cridar la funció en registrar-la: addEventListener('click', fer()) registra el valor retornat, no la funció. Sense parèntesis, o embolcallada en una fletxa si necessites arguments.
  • Escriure el tipus amb on: addEventListener('onclick', …) no dona error i no funciona mai, perquè no existeix cap esdeveniment anomenat onclick. El tipus és 'click'.
  • Intentar retirar una fletxa anònima. removeEventListener compara per identitat: si no vas desar la referència, no la pots retirar. Desa la funció en una constant, o fes servir { once: true } o AbortController (06-04).
  • Confondre target amb currentTarget. currentTarget és el teu element; target és on ha passat el clic, que pot ser un fill. Si el botó conté un <span> amb la icona, target serà el <span>.
  • Desar l'esdeveniment per fer-lo servir més tard. currentTarget val null fora del gestor. Extreu el que necessitis a variables abans de qualsevol setTimeout o await.
  • Oblidar preventDefault() en un submit. La pàgina es recarrega, l'estat es perd i sembla que «no funciona res». És l'error número u de la lliçó 06-07.
  • Confondre input amb change. input es dispara a cada pulsació; change només en confirmar. Per validar al vol vols input; per reaccionar a un <select>, qualsevol dels dos.
  • Fer servir <div> com a botó. Trenca el teclat i els lectors de pantalla. <button type="button"> sempre; i amb type explícit, perquè dins d'un formulari el valor per defecte és submit.
  • Consell: posa nom als teus gestors. enFerClicAAvancar en una funció amb nom apareix a les traces de la pila i al panell d'Event Listeners de les DevTools; una fletxa anònima apareix com a (anonymous).
  • Consell: inspecciona els gestors registrats. Al panell Elements de les DevTools hi ha una pestanya Event Listeners que mostra tots els gestors d'un element i dels seus avantpassats. És la manera més ràpida de descobrir gestors duplicats.

Exercicis

Exercici 1 · Un comptador de clics amb once

Escriu un fragment que registri dos gestors sobre el botó de filtre «Totes»: un de normal que compti els clics i els imprimeixi, i un altre amb { once: true } que imprimeixi «primer clic» i desaparegui. Comprova amb quatre clics que el primer s'executa quatre vegades i el segon una. Després afegeix un tercer gestor que puguis retirar des de la consola, i retira'l.

Exercici 2 · Dreceres de teclat accessibles

Afegeix a la pàgina aquestes dreceres globals, respectant la regla de no activar-les mentre s'escriu en un camp:

  • 1, 2, 3 filtren per Marta, Iván i Lucía respectivament.
  • 0 treu el filtre.
  • Escape treu el filtre i torna el focus al primer botó de la barra.

Reutilitza els botons existents en lloc de duplicar la lògica de filtratge.

Exercici 3 · Vista prèvia en passar el ratolí

Quan el punter entri en un <li> de tasca, mostra al paràgraf de resum una línia amb el títol, el revisor i els dies que falten fins a la data límit; en sortir, restaura el resum general. Fes servir els esdeveniments correctes perquè el missatge no parpellegi en passar el ratolí per sobre del botó interior, i explica per què aquella parella d'esdeveniments és l'adequada.

Solucions

Exercici 1

const botoTotes = document.querySelector('.filtre[data-responsable=""]');

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

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

// Tercer gestor, retirable: cal desar la referència
function avisar(esdeveniment) {
  console.log('avisant des de', esdeveniment.currentTarget.textContent);
}
botoTotes.addEventListener('click', avisar);

// Més tard, des de la consola:
botoTotes.removeEventListener('click', avisar);   // ✓ funciona: mateixa referència

Amb quatre clics la consola mostra clics: 1, primer clic, clics: 2, clics: 3, clics: 4. L'ordre dins d'un mateix clic és el de registre. El comptador funciona perquè clics viu al closure del gestor (03-04): cada crida veu i actualitza la mateixa variable.

Exercici 2

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

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

document.addEventListener('keydown', (esdeveniment) => {
  // Guarda: les dreceres no apliquen mentre s'escriu
  if (esdeveniment.target.matches('input, textarea, select, [contenteditable]')) return;
  if (esdeveniment.ctrlKey || esdeveniment.metaKey || esdeveniment.altKey) return;   // no trepitjar dreceres del sistema

  if (Object.hasOwn(PER_TECLA, esdeveniment.key)) {
    esdeveniment.preventDefault();
    premerFiltre(PER_TECLA[esdeveniment.key]);
    return;
  }

  if (esdeveniment.key === 'Escape') {
    premerFiltre('');
    barra.querySelector('.filtre').focus();
  }
});

L'elegant d'aquesta solució és boto.click(): en lloc de duplicar la lògica de filtratge, simula el clic sobre el botó que ja la té, i així l'estat visual (aria-pressed) queda coherent sense escriure ni una línia més. L'esdeveniment que genera click()isTrusted: false, però per la resta és idèntic. L'objecte PER_TECLA és el diccionari de consulta de 02-03, molt més net que una cadena d'if. I tornar el focus després d'Escape és una cortesia imprescindible: sense ella, qui navega amb teclat es queda sense punt de partida.

Exercici 3

import { dataLlegible } from './util/dates.js';

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

for (const li of llista.querySelectorAll('li[data-id]')) {
  const tasca = tauler.cercarPerId(Number(li.dataset.id));

  li.addEventListener('mouseenter', () => {
    resum.textContent =
      `${tasca.titol} · revisa ${tasca.revisor ?? 'ningú'} · ` +
      `límit ${dataLlegible(tasca.dataLimit)} (${tasca.diesRestants} dies)`;
  });

  li.addEventListener('mouseleave', () => {
    pintarResum(resum, tauler, AVUI);
  });
}

La parella correcta és mouseenter/mouseleave perquè no es disparen en moure's entre els fills de l'element. Amb mouseover/mouseout el missatge parpellejaria: en passar del <span> del títol al <button>, saltaria un mouseout (que restauraria el resum) seguit d'un mouseover (que el tornaria a posar), desenes de vegades per segon. mouseenter només es dispara en creuar la frontera exterior del <li>.

Un apunt d'accessibilitat: aquesta vista prèvia només existeix per a qui fa servir ratolí. Per fer-la completa caldria afegir els equivalents de teclat (focusin/focusout sobre un element enfocable), i això encaixa amb els esdeveniments que sí que fan bombolla de la lliçó següent.

Conclusió

Ja saps fer que la pàgina respongui. Un esdeveniment és una notificació que genera el navegador, un gestor és una funció que registres perquè te la cridin, i el mecanisme que ho fa possible és el mateix bucle d'esdeveniments de 05-07: el teu gestor s'encua com una tasca i s'executa quan la pila queda lliure, d'on se segueix la regla que un gestor ha de ser breu, perquè mentre corre la interfície està congelada.

De les tres maneres històriques de registrar gestors —l'atribut onclick, la propietat element.onclick i addEventListener— només l'última és acceptable: separa marcatge i lògica, admet diversos gestors per esdeveniment, funciona amb mòduls i accepta opcions. Coneixes aquelles opcions: once per al que només passa una vegada, passive per no bloquejar el desplaçament, i capture i signal que es despleguen a la lliçó següent. I coneixes el parany de removeEventListener: compara per identitat, així que una fletxa anònima o un bind en línia són impossibles de retirar; cal desar la referència, i cal retirar els gestors de window i document que ja no es facin servir per no retenir memòria.

De l'objecte Event domines l'essencial: type, timeStamp, isTrusted, la diferència crucial entre target (on ha passat) i currentTarget (on escoltes) —amb l'avís que aquest últim es buida en sortir del gestor—, i preventDefault() per cancel·lar l'acció per defecte del navegador, que serà imprescindible al submit del formulari. Tens la taula de referència d'esdeveniments de ratolí, teclat, focus i formulari, amb les distincions que més es confonen: input davant de change, mouseenter davant de mouseover, key davant de code. Saps que this en un gestor tradicional és l'element i en una fletxa és el de l'àmbit exterior, i que el criteri sensat dins d'una classe és fer servir fletxes i llegir l'element d'esdeveniment.currentTarget. I tens clar, sense excuses, que un control s'escriu amb <button>: el <div> amb click no rep focus, no respon a Enter ni a Espai i no s'anuncia com a botó.

A Nómada Tasques això ha donat js/vista/controlador.js amb connectarAccions i connectarFiltres: cada tasca avança pendent → en-curs → feta amb la regla R6 validada pel model, el botó canvia de text i es desactiva en acabar, el resum s'actualitza, i la barra de filtres fa servir aria-pressed i hidden per comunicar l'estat a tothom.

I ha aparegut un problema que no es pot ignorar: hi ha un gestor per botó. Sis tasques, sis gestors; i tan bon punt comencis a crear els <li> des de JavaScript, els elements nous naixeran sense gestor. La solució no és reconnectar cada vegada, sinó entendre com viatja un esdeveniment per l'arbre —captura, objectiu i bombolla— i col·locar un únic gestor al <ul> que atengui tots els botons que existeixin ara i en el futur. Aquell patró, juntament amb les eines per aturar el recorregut d'un esdeveniment i amb els esdeveniments personalitzats que et permetran desacoblar la vista del model, és Propagació, Delegació i Esdeveniments Personalitzats.

Curs de JavaScript: De Principiant a Avançat

Mòdul 1: Introducció a JavaScript

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats