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
- Què és un esdeveniment i què és la programació dirigida per esdeveniments
- Les tres maneres de registrar un gestor
addEventListenera fons- Les opcions:
once,passive,capture,signal removeEventListeneri el problema de la referència- L'objecte
Event preventDefaulti les accions per defecte- Taula de referència d'esdeveniments
- Esdeveniments de teclat i dreceres
thisdins d'un gestor- Accessibilitat: per què un
<div>ambclickno n'hi ha prou - Nómada Tasques: marcar com a feta i filtrar per responsable
- Errors Habituals i Consells
- Exercicis
- Conclusió
- 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).
- 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.
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 |
Sí | No | No | Sí | Només en codi molt antic |
addEventListener |
Sí | Sí | Sí | Sí | Sempre |
addEventListener a fons
addEventListener a fonsLa signatura és senzilla i sempre la mateixa:
tipus: el nom de l'esdeveniment com a cadena, sense el prefixon:'click', no'onclick'.gestor: la funció que s'executarà. Rep un argument: l'objecteEvent.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:
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:
I si li has de passar arguments? Embolcalla-la en una fletxa, com vas aprendre a 03-06:
- Les opcions:
once, passive, capture, signal
once, passive, capture, signalEl 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:
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.
removeEventListener i el problema de la referència
removeEventListener i el problema de la referènciaPer 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); // ✓ retiratI 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.
- L'objecte
Event
EventCada 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: LIQue 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.
preventDefault i les accions per defecte
preventDefault i les accions per defecteMolts 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ésfalse,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.
- 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 confirmarI 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.
- 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
});
this dins d'un gestor
this dins d'un gestorAquí 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.
- Accessibilitat: per què un
<div> amb click no n'hi ha prou
<div> amb click no n'hi ha prouAixò funciona amb el ratolí:
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
disabledde 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í |
| S'activa amb Enter/Espai | No (s'ha de programar) | Sí |
| S'anuncia com a botó | No (cal role="button") |
Sí |
Admet disabled |
No | Sí |
| Estil controlable amb CSS | Sí | Sí |
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>
- 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 anomenatonclick. El tipus és'click'. - Intentar retirar una fletxa anònima.
removeEventListenercompara per identitat: si no vas desar la referència, no la pots retirar. Desa la funció en una constant, o fes servir{ once: true }oAbortController(06-04). - Confondre
targetambcurrentTarget.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,targetserà el<span>. - Desar l'esdeveniment per fer-lo servir més tard.
currentTargetvalnullfora del gestor. Extreu el que necessitis a variables abans de qualsevolsetTimeoutoawait. - Oblidar
preventDefault()en unsubmit. 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
inputambchange.inputes dispara a cada pulsació;changenomés en confirmar. Per validar al vol volsinput; 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 ambtypeexplícit, perquè dins d'un formulari el valor per defecte éssubmit. - Consell: posa nom als teus gestors.
enFerClicAAvancaren 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,3filtren per Marta, Iván i Lucía respectivament.0treu el filtre.Escapetreu 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ènciaAmb 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() té 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
- Què és JavaScript?
- Configuració del teu Entorn de Desenvolupament
- El teu Primer Programa en JavaScript
- Sintaxi i Conceptes Bàsics de JavaScript
- Variables i Tipus de Dades
- Operadors Bàsics
- Conversió de Tipus i Comparacions
- El Projecte del Curs: Nómada Tasques
Mòdul 2: Estructures de Control
- Sentències Condicionals
- Bucles: for, while, do-while
- Sentències Switch
- Control del Flux: break, continue i Bucles Imbricats
- Gestió d'Errors amb try-catch
Mòdul 3: Funcions
- Definició i Crida de Funcions
- Expressions de Funció i Funcions Fletxa
- Paràmetres i Valors de Retorn
- Àmbit i Closures
- Hoisting i el Context d'Execució
- Funcions d'Ordre Superior
- Recursivitat
Mòdul 4: Objectes i Arrays
- Introducció als Objectes
- Mètodes d'Objecte i la Paraula Clau
this - Arrays: Conceptes Bàsics i Mètodes
- Iteració sobre Arrays
- Cercar, Ordenar i Agregar Dades: find, sort i reduce
- Desestructuració d'Arrays
- Desestructuració d'Objectes, Spread i Rest
- JSON i Còpies d'Objectes
Mòdul 5: Objectes i Funcions Avançades
- Prototips i Herència
- Classes i Programació Orientada a Objectes
- Encapsulació: Getters, Setters i Camps Privats
- Mòduls i Importació/Exportació
- JavaScript Asíncron: Callbacks
- Promeses i Async/Await
- El Bucle d'Esdeveniments i la Cua de Microtasques
- Iteradors i Generadors
Mòdul 6: El Model d'Objectes del Document (DOM)
- Introducció al DOM
- Selecció i Manipulació d'Elements del DOM
- Gestió d'Esdeveniments
- Propagació, Delegació i Esdeveniments Personalitzats
- Creació i Eliminació d'Elements del DOM
- Renderitzat de Llistes i Plantilles HTML
- Gestió i Validació de Formularis
Mòdul 7: APIs del Navegador i Temes Avançats
- Emmagatzematge Local i de Sessió
- Fetch API i AJAX
- Peticions Robustes: Errors, Timeouts i AbortController
- WebSockets
- Service Workers i Aplicacions Web Progressives (PWAs)
- APIs del Navegador Essencials
- Introducció a WebAssembly
Mòdul 8: Proves i Depuració
- Depuració de JavaScript
- Qualitat de Codi: ESLint, Prettier i Convencions
- Proves Unitàries amb Jest
- Dobles de Prova: Mocks, Stubs i Spies
- Proves d'Integració
- Proves d'Extrem a Extrem amb Cypress
Mòdul 9: Rendiment i Optimització
- Mesurar Abans d'Optimitzar: DevTools i Web Vitals
- Optimització del Rendiment de JavaScript
- Gestió de Memòria
- Manipulació Eficient del DOM
- Càrrega Diferida i Divisió de Codi
Mòdul 10: Frameworks i Llibreries de JavaScript
- Per Què Existeixen els Frameworks
- Introducció a React
- Gestió d'Estat amb Redux
- Conceptes Bàsics de Vue.js
- Conceptes Bàsics d'Angular
- Triar el Framework Adequat
