El tauler de Nómada Tasques ja es dibuixa sol, es filtra, s'ordena i respon als clics. Però la Marta continua sense poder fer el més elemental: crear una tasca. El backlog és el de dades/backlog.js i prou. Donar d'alta una tasca significa un formulari, i un formulari és molt més que uns quants <input>: és el punt on entren dades que no controles, on tot arriba com a text, on cal decidir què es valida al navegador i què al model, i on l'accessibilitat deixa de ser un detall per tornar-se imprescindible —un missatge d'error que només es veu en vermell no existeix per a qui no veu la pantalla—. En aquesta lliçó construiràs el formulari d'alta complet, amb validació en dues capes, missatges accessibles, validació al vol amb debounce i gestió correcta del focus. És l'última peça del Mòdul 6.
Contingut
- El formulari d'alta: HTML semàntic
- Els atributs dels camps
- Llegir valors:
value,FormDataiObject.fromEntries - Tot arriba com a text
- L'esdeveniment
submitipreventDefault - Validació nativa d'HTML5
- Validació nativa davant de validació en JavaScript
- Les regles de negoci viuen al model
- Mostrar errors de manera accessible
- Validació al vol amb
inputi debounce - Enviar, reiniciar i tornar el focus
- Nómada Tasques:
vista/formulari.js - Errors Habituals i Consells
- Exercicis
- Conclusió
- El formulari d'alta: HTML semàntic
Comencem pel marcatge, perquè un formulari ben escrit resol la meitat de la feina abans de tocar JavaScript:
<section aria-labelledby="titol-nova">
<h2 id="titol-nova">Nova tasca</h2>
<form id="nova-tasca" class="formulari" novalidate>
<p id="errors-form" class="errors" role="alert" hidden></p>
<fieldset>
<legend>Dades de la tasca</legend>
<div class="camp">
<label for="titol">Títol <span aria-hidden="true">*</span></label>
<input type="text" id="titol" name="titol" required
minlength="3" maxlength="80" autocomplete="off"
aria-describedby="ajuda-titol">
<small id="ajuda-titol" class="ajuda">Entre 3 i 80 caràcters.</small>
</div>
<div class="camp">
<label for="responsable">Responsable</label>
<select id="responsable" name="responsable">
<option value="">Sense assignar</option>
<option value="Marta">Marta</option>
<option value="Iván">Iván</option>
<option value="Lucía">Lucía</option>
</select>
</div>
<div class="camp">
<label for="prioritat">Prioritat</label>
<select id="prioritat" name="prioritat">
<option value="alta">Alta</option>
<option value="mitjana" selected>Mitjana</option>
<option value="baixa">Baixa</option>
</select>
</div>
<div class="camp">
<label for="horesEstimades">Hores estimades <span aria-hidden="true">*</span></label>
<input type="number" id="horesEstimades" name="horesEstimades"
required min="1" max="40" step="1" inputmode="numeric"
aria-describedby="ajuda-hores">
<small id="ajuda-hores" class="ajuda">Entre 1 i 40 hores (regla R3).</small>
</div>
<div class="camp">
<label for="dataLimit">Data límit <span aria-hidden="true">*</span></label>
<input type="date" id="dataLimit" name="dataLimit" required min="2026-09-20">
</div>
<div class="camp">
<label for="etiquetes">Etiquetes</label>
<input type="text" id="etiquetes" name="etiquetes"
pattern="[a-zA-Zàèéíòóúüïç·ÀÈÉÍÒÓÚÜÏÇ0-9, ]*" aria-describedby="ajuda-etiquetes">
<small id="ajuda-etiquetes" class="ajuda">Separades per comes: serigrafia, magatzem</small>
</div>
<div class="camp">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="2" maxlength="200"></textarea>
</div>
</fieldset>
<div class="accions">
<button type="submit">Crear tasca</button>
<button type="reset">Netejar</button>
</div>
</form>
</section>Les decisions que importen:
- Cada camp té la seva
<label for="…">apuntant a l'iddel control. Això no és opcional: és el que fa que un lector de pantalla anunciï «Títol, camp de text, requerit» i el que permet activar el camp fent clic a l'etiqueta. Unplaceholderno substitueix una etiqueta: desapareix en escriure i molts lectors no l'anuncien. namea cada control. És la clau amb què la dada apareixerà aFormData. Sensename, el camp simplement no s'envia. I aquí els hem fet coincidir amb els noms de les propietats deTasca(titol,horesEstimades,dataLimit), cosa que estalviarà molta feina a l'apartat 3.<fieldset>amb<legend>agrupa camps relacionats i els dona un nom comú, que els lectors de pantalla anuncien en entrar al grup. És imprescindible amb grups de radios i molt recomanable en general.aria-describedbyconnecta el camp amb el seu text d'ajuda, de manera que es llegeix juntament amb el nom del camp.role="alert"al contenidor d'errors: quan hi posis text, els lectors de pantalla l'anunciaran immediatament, sense que l'usuari l'hagi d'anar a buscar.novalidateal<form>desactiva els globus d'error del navegador, però no desactiva l'API de validació: continuem podent consultarcheckValidity(). És el que ens permetrà mostrar els errors amb el nostre propi disseny i amb l'accessibilitat que vulguem. Hi tornarem a l'apartat 6.
- Els atributs dels camps
L'HTML modern té molta més capacitat de validació de la que se sol fer servir:
| Atribut | S'aplica a | Què fa |
|---|---|---|
required |
Gairebé tots | El camp no pot quedar buit |
minlength / maxlength |
text, textarea, search |
Longitud del text (maxlength a més impedeix escriure'n més) |
min / max |
number, date, range |
Valor mínim i màxim |
step |
number, date, range |
Increment permès (step="1" = només enters) |
pattern |
text, tel, search… |
Expressió regular que el valor ha de complir |
type |
Tots | Valida el format: email, url, number, date |
autocomplete |
Gairebé tots | Suggeriments del navegador (off per a camps únics) |
inputmode |
Text i número | Quin teclat mostra el mòbil (numeric, decimal, tel) |
readonly / disabled |
Gairebé tots | Només lectura / desactivat (disabled no s'envia) |
Dos detalls que s'obliden sovint:
type="number"no impedeix escriure lletres a tots els navegadors, però sí que fa queinput.valueretorni cadena buida si el contingut no és un número vàlid. És un comportament que sorprèn: el camp sembla que té text ivalueestà buit.disabledexclou el camp de l'enviament;readonlyl'hi inclou. Si necessites mostrar un valor fix i que arribi a l'enviament,readonly.
- Llegir valors:
value, FormData i Object.fromEntries
value, FormData i Object.fromEntriesHi ha tres nivells, de més manual a més automàtic.
Nivell 1: camp a camp.
const formulari = document.querySelector('#nova-tasca');
const titol = formulari.querySelector('#titol').value;
const hores = formulari.querySelector('#horesEstimades').value;Funciona, però cada camp nou són dues línies més i un id que pot quedar desactualitzat.
Nivell 2: form.elements. Tot formulari té una col·lecció dels seus controls, accessible per name:
console.log(formulari.elements.titol.value); // per nom
console.log(formulari.elements['horesEstimades'].value);
console.log(formulari.elements.length); // nombre de controlsÉs còmoda per accedir a un control concret sense buscar-lo amb querySelector.
Nivell 3: FormData + Object.fromEntries. Aquesta és la forma idiomàtica:
const dades = new FormData(formulari);
// FormData és iterable: parells [nom, valor]
for (const [clau, valor] of dades) {
console.log(clau, '=', JSON.stringify(valor));
}
// titol = "Reposar tinta blanca"
// responsable = "Lucía"
// prioritat = "alta"
// horesEstimades = "4"
// dataLimit = "2026-10-10"
// etiquetes = "serigrafia, magatzem"
// notes = ""
// I amb Object.fromEntries (04-05), un objecte pla d'una sola línia:
const brut = Object.fromEntries(dades);
console.log(brut);
// { titol: 'Reposar tinta blanca', responsable: 'Lucía', prioritat: 'alta',
// horesEstimades: '4', dataLimit: '2026-10-10', etiquetes: 'serigrafia, magatzem', notes: '' }Aquí es paga el dividend d'haver posat name="horesEstimades" en lloc de name="hores": les claus de l'objecte ja coincideixen amb les propietats que espera el constructor de Tasca.
Dues limitacions d'Object.fromEntries que convé conèixer:
- Amb noms repetits, es queda amb l'últim. Un grup de caselles amb el mateix
namerequereixdades.getAll('etiquetes'), que retorna un array amb tots els valors. - Les caselles sense marcar no apareixen a
FormData. No valenfalse: senzillament no hi són. Cal donar per fet que hi faltaran, amb?? falseo consultant la propietatchecked.
- Tot arriba com a text
Mira una altra vegada la sortida de dalt: horesEstimades val '4', la cadena, no el número. És la mateixa frontera que ja vas veure amb dataset a 06-02 i la raó de ser de la lliçó 01-07.
const brut = Object.fromEntries(new FormData(formulari));
console.log(typeof brut.horesEstimades); // 'string'
console.log(brut.horesEstimades + 1); // '41' ← concatenació
console.log(brut.horesEstimades > 40); // false ← '4' > 40 compara malamentAquell últim cas és especialment traïdor: '4' > 40 es converteix a 4 > 40 i dona false per casualitat; però '9' > 40 també dona false, i '10' > '9' dona false perquè compara cadenes. Qualsevol validació numèrica sobre text sense convertir és una bomba de rellotgeria.
La solució és una funció de conversió de frontera explícita, amb Number (mai parseInt, que accepta '12abc' i retorna 12):
/** Converteix les dades crues del formulari als tipus del model. */
function normalitzar(brut) {
return {
titol: brut.titol.trim(),
responsable: brut.responsable === '' ? null : brut.responsable, // R8
prioritat: brut.prioritat,
horesEstimades: Number(brut.horesEstimades), // '4' → 4
dataLimit: brut.dataLimit, // ja és 'yyyy-mm-dd'
etiquetes: brut.etiquetes
.split(',')
.map((e) => e.trim().toLowerCase())
.filter((e) => e !== ''), // R9
notes: brut.notes.trim()
};
}
console.log(normalitzar(brut));
// { titol: 'Reposar tinta blanca', responsable: 'Lucía', prioritat: 'alta',
// horesEstimades: 4, dataLimit: '2026-10-10',
// etiquetes: ['serigrafia', 'magatzem'], notes: '' }Un avís sobre Number amb la cadena buida: Number('') és 0, no NaN. Si un camp numèric obligatori s'envia buit i el model espera un número, 0 passaria la conversió i fallaria després amb un missatge confús. Per això el required de l'HTML i la validació del model es complementen.
I una nota sobre type="date": el seu value és sempre una cadena 'yyyy-mm-dd', exactament el format ISO que fa servir Nómada Tasques. Existeix a més input.valueAsNumber (mil·lisegons) i valueAsDate (un objecte Date), però per a nosaltres la cadena és la idònia, perquè és el que desa el model.
- L'esdeveniment
submit i preventDefault
submit i preventDefaultL'enviament d'un formulari s'escolta al <form>, no al botó:
formulari.addEventListener('submit', (esdeveniment) => {
esdeveniment.preventDefault(); // ← sense això, la pàgina es recarrega i ho perds tot
// … processar
});Sense preventDefault(), el navegador fa el que feia el 1995: serialitza el formulari, navega a la URL de l'atribut action (o recarrega l'actual) i la teva aplicació sencera torna a començar. És l'error número u amb formularis i produeix el símptoma desconcertant de «la pàgina parpelleja i no passa res».
Detalls útils sobre el submit:
- Es dispara amb Enter dins d'un camp de text, no només en prémer el botó. És una comoditat esperada pels usuaris: no la trenquis escoltant
clickal botó en lloc desubmital formulari. <button>dins d'un formulari éstype="submit"per defecte. Un botó que fa una altra cosa necessitatype="button"explícit, o enviarà el formulari sense voler.esdeveniment.submitterindica quin botó ha provocat l'enviament, útil si n'hi ha diversos («Crear» i «Crear i duplicar»).type="reset"dispara un esdevenimentreseti buida els camps; també és cancel·lable.
- Validació nativa d'HTML5
El navegador porta un motor de validació complet, i malgastar-lo és un error freqüent. Cada control té:
const camp = formulari.elements.horesEstimades;
console.log(camp.validity);
// ValidityState { valueMissing: false, rangeOverflow: true, badInput: false, valid: false, … }
console.log(camp.checkValidity()); // false · és vàlid? (dispara un esdeveniment 'invalid')
console.log(camp.validationMessage); // 'El valor ha de ser menor o igual que 40.'
console.log(camp.willValidate); // true · participa en la validació?L'objecte validity té una bandera per tipus de fallada, i això permet missatges precisos:
| Bandera | S'activa quan |
|---|---|
valueMissing |
És required i està buit |
typeMismatch |
No compleix el type (email, url) |
patternMismatch |
No compleix el pattern |
tooShort / tooLong |
Incompleix minlength / maxlength |
rangeUnderflow / rangeOverflow |
Incompleix min / max |
stepMismatch |
No encaixa amb el step |
badInput |
El navegador no pot interpretar el que s'ha escrit (lletres en type="number") |
customError |
Tu has cridat setCustomValidity |
valid |
Cap de les anteriors |
A nivell de formulari:
formulari.checkValidity(); // són vàlids TOTS els camps? (sense mostrar res)
formulari.reportValidity(); // igual, però a més MOSTRA els globus del navegadorI setCustomValidity permet afegir un error propi al motor natiu:
const data = formulari.elements.dataLimit;
data.setCustomValidity('La data límit no pot ser anterior a avui.');
console.log(data.validity.customError); // true
console.log(data.checkValidity()); // false
data.setCustomValidity(''); // ← cadena buida = camp vàlid una altra vegadaRegla d'or amb setCustomValidity: cal netejar-lo. Si hi poses un missatge i no l'esborres quan l'usuari corregeix, el camp queda invàlid per sempre i el formulari no s'enviarà mai, sense cap pista de per què.
En CSS pots reaccionar a la validesa amb pseudoclasses:
.camp input:user-invalid,
.camp select:user-invalid { border-color: var(--color-alta); }
.camp input:user-valid { border-color: var(--color-baixa); }:user-invalid és preferible a :invalid perquè només s'activa després que l'usuari hagi interactuat amb el camp. Amb :invalid a seques, tots els camps obligatoris apareixen en vermell tot just carregar la pàgina, abans que ningú hagi escrit res: una experiència hostil.
- Validació nativa davant de validació en JavaScript
| Aspecte | Validació nativa (HTML5) | Validació en JavaScript |
|---|---|---|
| Esforç | Un atribut | Codi a escriure i mantenir |
| Funciona sense JS | Sí | No |
| Control del missatge | Limitat (idioma del navegador) | Total |
| Control del disseny i del moment | Escàs | Total |
| Regles entre diversos camps | No pot | Sí |
| Regles de negoci (unicitat, quotes) | No pot | Sí |
| Accessibilitat | Bona per defecte | S'ha de construir |
La resposta correcta no és triar-ne una: és fer servir totes dues en capes.
- Capa HTML:
required,min,max,pattern,type. Barata, funciona sense JavaScript, guia l'usuari mentre escriu i fa que el mòbil mostri el teclat adequat. - Capa JavaScript de la vista: missatges propis, moment de mostrar-los, accessibilitat, regles entre camps.
- Capa del model: les regles de negoci de debò, que ja existeixen des del Mòdul 5 i que no es dupliquen.
I una quarta capa que no és opcional en una aplicació real: el servidor. Tota la validació del navegador es pot saltar amb les DevTools en deu segons. Serveix per ajudar l'usuari, mai per garantir la integritat de les dades. Nómada Tasques encara no té servidor; el tindrà al Mòdul 7.
- Les regles de negoci viuen al model
Aquesta és la decisió d'arquitectura més important de la lliçó. Mira el que ja sap fer Tasca des de 05-02 i 05-03:
- R1:
Tauler.afegirrebutja ids duplicats. - R2: el constructor llança
ErrorDeValidaciosi el títol està buit. - R3: el setter d'
horesEstimadesexigeix un número entre 1 i 40. - R5: l'estat inicial és
'pendent'. - R9: les etiquetes es normalitzen i es desdupliquen.
Seria un error reescriure tot allò a la vista. Si ho fessis, tindries dues còpies de cada regla, que es desincronitzarien a la primera modificació, i el Mòdul 8 les hauria de provar dues vegades.
El correcte és intentar construir la tasca i capturar l'error. La vista no valida regles de negoci: es limita a traduir l'error del model en un missatge a la pantalla.
import { Tasca } from '../model/tasca.js';
import { ErrorDeValidacio } from '../model/errors.js';
function intentarCrear(dades, tauler, seguentId) {
try {
const tasca = new Tasca({ ...dades, id: seguentId() }); // R2, R3, R5, R9
tauler.afegir(tasca); // R1
return { ok: true, tasca };
} catch (error) {
if (error instanceof ErrorDeValidacio) {
return { ok: false, camp: error.camp, missatge: error.message };
}
throw error; // qualsevol altre error és una fallada real: que pugi (02-05)
}
}Aquell error.camp és or: l'ErrorDeValidacio que vas dissenyar a 05-02 ja porta el nom del camp que ha fallat, així que la vista sap exactament on posar el missatge i a quin camp moure el focus. Dissenyar així els errors, mesos abans de tenir una interfície, és el que fa que ara encaixin sense esforç.
Els ids els genera el closure de 03-04, que continua sent la manera correcta de no exposar un comptador global:
// js/util/ids.js
export function crearGeneradorIds(inicial = 1) {
let seguent = inicial;
return () => seguent++;
}// js/app.js
import { crearGeneradorIds } from './util/ids.js';
const seguentId = crearGeneradorIds(
Math.max(...tauler.tasques.map((t) => t.id)) + 1 // 7, amb el backlog canònic
);Queden les validacions que sí que pertanyen a la vista, perquè no són regles del domini sinó d'aquest formulari concret: que la data límit no sigui anterior a avui, que el camp d'etiquetes no en porti més de cinc, o qualsevol comprovació que depengui de la relació entre dos camps.
- Mostrar errors de manera accessible
Un missatge d'error ha de complir quatre coses. Si en falla una, hi ha gent que no pot fer servir el teu formulari:
- Estar associat al camp, amb
aria-describedby, perquè es llegeixi juntament amb ell. - Marcar el camp com a invàlid, amb
aria-invalid="true", perquè s'anunciï com a tal. - Anunciar-se en aparèixer, amb
role="alert"al resum general. - Moure el focus al primer camp amb error, perquè qui navega amb teclat no l'hagi de buscar.
I un cinquè requisit que no és tècnic: el color no pot ser l'única senyal. Una vora vermella sense text no diu res a qui no distingeix el vermell.
// js/vista/formulari.js (fragment)
import { $ } from './dom.js';
/** Marca un camp com a erroni i mostra el seu missatge. */
function marcarError(camp, missatge) {
camp.setAttribute('aria-invalid', 'true');
const idError = `error-${camp.name}`;
let error = document.getElementById(idError);
if (error === null) {
error = document.createElement('small');
error.id = idError;
error.className = 'camp__error';
camp.closest('.camp').append(error);
}
error.textContent = missatge;
// Afegim l'id de l'error a aria-describedby SENSE esborrar el de l'ajuda
const descritPer = (camp.getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
if (!descritPer.includes(idError)) {
camp.setAttribute('aria-describedby', [...descritPer, idError].join(' '));
}
}
/** Neteja l'error d'un camp. */
function netejarError(camp) {
camp.removeAttribute('aria-invalid');
camp.setCustomValidity(''); // ← imprescindible
document.getElementById(`error-${camp.name}`)?.remove();
const descritPer = (camp.getAttribute('aria-describedby') ?? '')
.split(' ').filter((id) => id !== `error-${camp.name}` && id !== '');
if (descritPer.length > 0) camp.setAttribute('aria-describedby', descritPer.join(' '));
else camp.removeAttribute('aria-describedby');
}.camp__error { color: var(--color-alta); font-weight: 600; display: block; }
.camp__error::before { content: "⚠ "; } /* senyal no cromàtica */
[aria-invalid="true"] { border: 2px solid var(--color-alta); }
.errors { color: var(--color-alta); font-weight: 600; }Fixa't en la cura amb aria-describedby: pot contenir diversos ids separats per espais, i el camp ja tenia el del seu text d'ajuda. Esclafar-lo amb setAttribute('aria-describedby', idError) faria desaparèixer l'ajuda. És un detall petit amb conseqüències reals.
I el moviment del focus, que es fa una sola vegada, al primer error:
function mostrarErrors(formulari, errors) {
const resum = $('#errors-form');
if (errors.length === 0) {
resum.hidden = true;
resum.textContent = '';
return;
}
resum.textContent = errors.length === 1
? errors[0].missatge
: `Hi ha ${errors.length} camps amb errors. Revisa-los abans de continuar.`;
resum.hidden = false; // role="alert" ho anuncia en mostrar-se
for (const { camp, missatge } of errors) marcarError(camp, missatge);
errors[0].camp.focus(); // ← el focus al PRIMER error
}
- Validació al vol amb
input i debounce
input i debounceValidar només en enviar és frustrant: l'usuari omple set camps i descobreix al final que el segon estava malament. Validar a cada tecla és igual de molest: apareix «el títol és massa curt» en escriure la primera lletra.
L'equilibri que funciona:
| Moment | Què validar |
|---|---|
blur / focusout |
El camp que s'acaba d'abandonar (primera vegada) |
input |
Només els camps ja marcats com a erronis, per treure l'error tan bon punt es corregeixi |
submit |
Tot |
I per a les validacions cares o sorolloses —una cerca de títols duplicats, per exemple— s'aplica un debounce: esperar que l'usuari deixi d'escriure durant una estona abans d'actuar. S'implementa amb el closure de 03-04:
// js/util/temps.js
/**
* Retorna una versió de `fn` que només s'executa quan han passat
* `espera` mil·lisegons des de l'última crida.
*/
export function debounce(fn, espera = 300) {
let temporitzador = null; // ← viu al closure, un per funció creada
return function (...args) {
clearTimeout(temporitzador); // cancel·la l'execució pendent
temporitzador = setTimeout(() => fn.apply(this, args), espera);
};
}const validarTitol = debounce((camp) => {
if (camp.value.trim().length < 3) marcarError(camp, 'Mínim 3 caràcters.');
else netejarError(camp);
}, 300);
formulari.elements.titol.addEventListener('input', (e) => validarTitol(e.target));Traça del que passa si la Marta escriu «Reposar tinta» en un segon:
R → programa validació d'aquí a 300 ms e → cancel·la l'anterior, en programa una altra p → cancel·la, programa … a → cancel·la, programa (pausa de 300 ms) → s'executa UNA vegada, amb el valor final
Tretze pulsacions, una sola validació. Amb dades locals l'estalvi és simbòlic; amb una comprovació contra un servidor (Mòdul 7) és la diferència entre tretze peticions i una.
No confonguis debounce amb throttle: el primer espera que parin els esdeveniments; el segon executa com a màxim una vegada cada X mil·lisegons, i és l'apropiat per a scroll o resize. Totes dues tècniques s'estudien amb detall a Optimització del Rendiment.
Un avís d'accessibilitat sobre el debounce: amb role="alert", cada canvi de text s'anuncia. Si valides al vol un camp mentre l'usuari escriu, el lector de pantalla interromprà constantment. Per això els missatges al vol van al <small> del camp (amb aria-describedby, que es llegeix quan l'usuari arriba al camp) i només el resum de l'enviament fa servir role="alert".
- Enviar, reiniciar i tornar el focus
Quan l'enviament va bé, queden tres gestos que separen un formulari acurat d'un de descurat:
formulari.reset(); // buida els camps (respecta els 'selected')
formulari.elements.titol.focus(); // torna el focus al primer camp
anunciar(`Tasca «${tasca.titol}» creada.`); // confirmació audible i visiblereset()torna cada control al seu valor inicial de l'HTML, no a la cadena buida: per això l'<option value="mitjana" selected>torna a quedar seleccionat. També neteja l'estat:user-invalid.- Tornar el focus al primer camp permet crear diverses tasques seguides sense tocar el ratolí. Si no ho fas, el focus es queda al botó «Crear» i l'usuari ha de retrocedir amb Shift+Tab set vegades.
- La confirmació ha de ser perceptible per tothom. Un text que apareix en un contenidor amb
role="status"s'anuncia sense interrompre; una animació silenciosa no.
Recorda també netejar els errors anteriors a cada enviament: si no ho fas, queden marcats camps que ja s'han corregit.
- Nómada Tasques:
vista/formulari.js
vista/formulari.jsEl mòdul complet, que ajunta les tres capes de validació:
// js/vista/formulari.js
import { $, $$ } from './dom.js';
import { debounce } from '../util/temps.js';
import { Tasca } from '../model/tasca.js';
import { ErrorDeValidacio } from '../model/errors.js';
import { ESDEVENIMENTS, emetre } from './esdeveniments.js';
import { AVUI } from '../util/dates.js';
/** Converteix les dades crues del formulari als tipus del model. */
function normalitzar(brut) {
return {
titol: (brut.titol ?? '').trim(),
responsable: brut.responsable === '' ? null : brut.responsable,
prioritat: brut.prioritat,
horesEstimades: brut.horesEstimades === '' ? NaN : Number(brut.horesEstimades),
dataLimit: brut.dataLimit,
etiquetes: (brut.etiquetes ?? '')
.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean)
};
}
/** Validacions pròpies d'AQUEST formulari (no regles de domini). */
function validarFormulari(formulari, dades, avui) {
const errors = [];
for (const camp of formulari.elements) {
if (camp.willValidate && !camp.checkValidity()) {
errors.push({ camp, missatge: camp.validationMessage }); // capa nativa
}
}
const data = formulari.elements.dataLimit;
if (errors.every((e) => e.camp !== data) && dades.dataLimit < avui) {
errors.push({ camp: data, missatge: 'La data límit no pot ser anterior a avui.' });
}
if (dades.etiquetes.length > 5) {
errors.push({ camp: formulari.elements.etiquetes, missatge: 'Màxim 5 etiquetes.' });
}
return errors;
}
export function connectarFormulari({ formulari, tauler, seguentId, enCrear,
avui = AVUI, signal }) {
const resum = $('#errors-form');
function netejarTot() {
resum.hidden = true;
resum.textContent = '';
for (const camp of formulari.elements) {
if (camp.name) netejarError(camp);
}
}
// ── 1 · Enviament ────────────────────────────────────────────────────────
formulari.addEventListener('submit', (esdeveniment) => {
esdeveniment.preventDefault(); // imprescindible
netejarTot();
const dades = normalitzar(Object.fromEntries(new FormData(formulari)));
// Capa A · validació del formulari (nativa + regles de la vista)
const errors = validarFormulari(formulari, dades, avui);
if (errors.length > 0) { mostrarErrors(formulari, errors); return; }
// Capa B · regles de negoci: les aplica el MODEL, no la vista
let tasca;
try {
tasca = new Tasca({ ...dades, id: seguentId(), estat: 'pendent' }); // R2, R3, R5, R9
tauler.afegir(tasca); // R1
} catch (error) {
if (!(error instanceof ErrorDeValidacio)) throw error;
const camp = formulari.elements[error.camp] ?? formulari.elements.titol;
mostrarErrors(formulari, [{ camp, missatge: error.message }]);
return;
}
// Èxit
emetre(formulari, ESDEVENIMENTS.TASCA_CREADA, { id: tasca.id, titol: tasca.titol });
enCrear?.(tasca); // qui crida decideix què fer (render)
formulari.reset();
formulari.elements.titol.focus();
resum.hidden = false;
resum.textContent = `Tasca «${tasca.titol}» creada amb l'identificador ${tasca.id}.`;
}, { signal });
// ── 2 · Validació en abandonar un camp ───────────────────────────────────
formulari.addEventListener('focusout', (esdeveniment) => { // focusin/focusout SÍ que fan bombolla
const camp = esdeveniment.target;
if (!camp.name || !camp.willValidate) return;
if (camp.value === '' && !camp.required) return; // no molestar amb camps opcionals
if (camp.checkValidity()) netejarError(camp);
else marcarError(camp, camp.validationMessage);
}, { signal });
// ── 3 · Correcció al vol, amb debounce ───────────────────────────────────
const revalidar = debounce((camp) => {
if (camp.checkValidity()) netejarError(camp);
}, 300);
formulari.addEventListener('input', (esdeveniment) => {
const camp = esdeveniment.target;
// Només revalidem el que JA estava marcat com a erroni
if (camp.getAttribute('aria-invalid') === 'true') revalidar(camp);
}, { signal });
// ── 4 · Reset net ────────────────────────────────────────────────────────
formulari.addEventListener('reset', () => {
netejarTot();
setTimeout(() => formulari.elements.titol.focus(), 0); // després del reset del navegador
}, { signal });
}I el cablejat final a app.js, amb el cicle complet del mòdul:
// js/app.js
import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';
import { crearGeneradorIds } from './util/ids.js';
import { AVUI } from './util/dates.js';
import { TaulerVista } from './vista/tauler-vista.js';
import { connectarFormulari } from './vista/formulari.js';
import { $ } from './vista/dom.js';
const tauler = new Tauler('Taller Nómada', crearBacklog());
const seguentId = crearGeneradorIds(Math.max(...tauler.tasques.map((t) => t.id)) + 1);
const vista = new TaulerVista({
contenidor: $('.tauler'), resum: $('#resum'), tauler, avui: AVUI
});
vista.render();
connectarFormulari({
formulari: $('#nova-tasca'),
tauler,
seguentId,
avui: AVUI,
enCrear: () => vista.render() // ← estat nou, render: el cicle de 06-06
});Prova-ho. Crea «Reposar tinta blanca», Lucía, prioritat alta, 4 h, 10 d'octubre, etiquetes «serigrafia, magatzem». La targeta apareix a la columna «Pendents», el comptador passa de 3 tasques · 25 h a 4 tasques · 29 h, i el resum de 6 tasques · 5 obertes · 45 de 48 h a 7 tasques · 6 obertes · 49 de 52 h. Prova després d'enviar amb el títol buit: el resum anuncia l'error, el camp es marca amb aria-invalid, apareix el missatge sota l'etiqueta i el focus salta al camp. I prova de posar 60 hores: el max="40" de l'HTML ho detecta abans d'arribar al model, i si te'l saltessis amb les DevTools, el setter d'horesEstimades (R3) ho rebutjaria igualment. Dues capes, la mateixa regla, una sola definició.
Errors Habituals i Consells
- Oblidar
preventDefault()alsubmit. La pàgina es recarrega i tot l'estat es perd. Si el teu formulari «parpelleja i no fa res», aquest és el motiu el 90 % de les vegades. - Escoltar
clickal botó en lloc desubmital formulari. Trenca l'enviament amb Enter, que molts usuaris donen per fet. - Posar
<button>sensetypeper a accions que no envien. Dins d'un formulari, el valor per defecte éssubmit. Qualsevol botó auxiliar necessitatype="button". - Fer servir
placeholderen lloc de<label>. Desapareix en escriure, no sempre s'anuncia i no s'hi pot fer clic. L'etiqueta és obligatòria; elplaceholderés un extra. - No convertir els valors. Tot arriba com a text:
'4' > 40donafalseper casualitat i'10' > '9'donafalseper comparació de cadenes. Converteix ambNumber()en una funció de normalització. - Oblidar
setCustomValidity('')en corregir. El camp queda invàlid per sempre i el formulari no s'envia mai, sense cap pista visible. - Esclafar
aria-describedby. Pot contenir diversos ids; sobreescriure'l elimina la referència al text d'ajuda. Afegeix i treu ids conservant els altres. - Duplicar les regles de negoci a la vista. Si
Tascaja valida el rang d'hores, la vista no ho ha de repetir: ha de capturar l'ErrorDeValidacioi fer servir el seucampi el seumessage. Una regla, un lloc. - Marcar-ho tot en vermell en carregar la pàgina. És el que fa
:invalida seques. Fes servir:user-invalid, que espera que l'usuari interactuï. - Confiar en la validació del client. Se salta amb les DevTools en deu segons. És una ajuda a l'usuari, no una garantia; la garantia la dona el servidor (Mòdul 7).
- Consell: fes coincidir els
nameamb les propietats del model.Object.fromEntries(new FormData(form))produeix llavors un objecte gairebé llest per al constructor, i t'estalvies un mapatge sencer. - Consell: prova el teu formulari només amb el teclat. Tab, Shift+Tab, Enter, Escape. Si el pots omplir, enviar, corregir un error i tornar-lo a enviar sense tocar el ratolí, està ben fet.
Exercicis
Exercici 1 · Revisor diferent del responsable
Afegeix al formulari un <select name="revisor"> amb les mateixes persones i una validació entre camps: el revisor no pot ser la mateixa persona que el responsable. S'ha de mostrar com a error accessible al camp del revisor, revalidar-se en canviar qualsevol dels dos, i no impedir que tots dos quedin sense assignar.
Exercici 2 · Comptador de caràcters accessible
Afegeix sota el camp de notes un comptador «120 / 200 caràcters» que s'actualitzi en escriure i avisi quan en quedin menys de 20. Ha de fer servir input, no interrompre els lectors de pantalla a cada tecla, i aplicar un debounce a l'anunci. Explica quina combinació d'aria-live i debounce has triat i per què.
Exercici 3 · Duplicats per títol
Impedeix crear dues tasques obertes amb el mateix títol (comparant sense distingir majúscules ni accents sobrants). Decideix raonadament en quina capa viu aquesta regla, implementa-la allà, i valida al vol amb debounce de 400 ms mostrant l'avís abans que l'usuari premi «Crear tasca».
Solucions
Exercici 1
<div class="camp">
<label for="revisor">Revisor</label>
<select id="revisor" name="revisor">
<option value="">Sense revisor</option>
<option value="Marta">Marta</option>
<option value="Iván">Iván</option>
<option value="Lucía">Lucía</option>
</select>
</div>function validarRevisor(formulari) {
const revisor = formulari.elements.revisor;
const responsable = formulari.elements.responsable;
const xoca = revisor.value !== '' && revisor.value === responsable.value;
revisor.setCustomValidity(xoca ? 'El revisor no pot ser el responsable.' : '');
if (xoca) marcarError(revisor, revisor.validationMessage);
else netejarError(revisor);
return !xoca;
}
// Es revalida en canviar QUALSEVOL dels dos
for (const nom of ['revisor', 'responsable']) {
formulari.elements[nom].addEventListener('change', () => validarRevisor(formulari));
}Dues decisions importants. La primera: es fa servir setCustomValidity, cosa que integra la regla al motor natiu i fa que el bucle checkValidity() de l'enviament la detecti sense codi addicional. La segona: s'escolta als dos camps, perquè l'error pot aparèixer o desaparèixer en canviar qualsevol d'ells; validar només el revisor deixaria un error obsolet si l'usuari corregeix canviant el responsable. I el revisor.value !== '' permet que tots dos quedin buits, tal com demanava l'enunciat: «sense assignar» i «sense revisor» no xoquen entre si.
Exercici 2
<small id="comptador-notes" class="ajuda" aria-live="polite">0 / 200 caràcters</small>
<textarea id="notes" name="notes" rows="2" maxlength="200"
aria-describedby="comptador-notes"></textarea>const notes = formulari.elements.notes;
const comptador = $('#comptador-notes');
// Actualització VISUAL immediata, sense aria-live actiu
const anunciar = debounce((text) => { comptador.textContent = text; }, 500);
notes.addEventListener('input', () => {
const usats = notes.value.length;
const restants = 200 - usats;
const text = `${usats} / 200 caràcters`;
comptador.classList.toggle('ajuda--avis', restants < 20);
anunciar(restants < 20 ? `${text}. En queden ${restants}.` : text);
});La combinació triada és aria-live="polite" més un debounce de 500 ms, i el raonament és aquest: polite fa que el lector esperi a acabar el que estigui dient abans d'anunciar el canvi, en lloc d'interrompre com faria assertive o role="alert". Però fins i tot sent polite, actualitzar el text a cada tecla encuaria desenes d'anuncis. El debounce garanteix que només s'anuncia quan l'usuari fa una pausa, que és just el moment en què la informació li resulta útil. La classe d'avís, en canvi, s'aplica immediatament: és informació visual, no costa res i no interromp ningú.
Exercici 3
La regla «no hi pot haver dues tasques obertes amb el mateix títol» és una regla de negoci, no de presentació: continua sent certa encara que la tasca es creï des d'un script, des d'una importació o des del servidor. Per tant viu al model, al costat de R1.
// model/tauler.js
#normalitzarTitol(titol) {
return titol.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
/** Hi ha ja una tasca OBERTA amb aquest títol? (R11) */
titolDuplicat(titol, exceptaId = null) {
const cercat = this.#normalitzarTitol(titol);
return this.#tasques.some(
(t) => t.oberta && t.id !== exceptaId && this.#normalitzarTitol(t.titol) === cercat
);
}
afegir(tasca) {
// … R1 …
if (this.titolDuplicat(tasca.titol)) {
throw new ErrorDeValidacio(
`Ja existeix una tasca oberta titulada «${tasca.titol}».`, 'titol', tasca.titol);
}
this.#tasques.push(tasca);
return this;
}// vista/formulari.js — avís al vol, sense duplicar la regla
const avisarDuplicat = debounce((camp) => {
const duplicat = tauler.titolDuplicat(camp.value);
camp.setCustomValidity(duplicat ? 'Ja existeix una tasca oberta amb aquest títol.' : '');
if (duplicat) marcarError(camp, camp.validationMessage);
else netejarError(camp);
}, 400);
formulari.elements.titol.addEventListener('input', (e) => avisarDuplicat(e.target));La clau és que la vista pregunta al model (tauler.titolDuplicat(...)) en lloc de reimplementar la comparació. Si demà es decideix que també hi compten les tasques fetes, o que cal ignorar la puntuació, es canvia una única funció i tant l'avís al vol com el rebuig de l'enviament es comporten igual. El normalize('NFD') seguit de l'eliminació dels diacrítics és la manera estàndard de comparar «serigrafía» i «serigrafia» com a iguals.
Conclusió
Amb això Nómada Tasques està completa com a aplicació de navegador. Saps escriure un formulari semàntic: cada control amb la seva <label for>, el seu name —fent-lo coincidir amb les propietats del model, cosa que estalvia un mapatge sencer—, els seus <fieldset> i <legend> per agrupar, els seus aria-describedby per a les ajudes i els seus atributs de validació (required, minlength, min, max, step, pattern, type="date", type="number"). Saps llegir els valors en tres nivells, de l'element.value a la forma idiomàtica Object.fromEntries(new FormData(formulari)), amb les dues limitacions que convé recordar: els noms repetits requereixen getAll, i les caselles sense marcar senzillament no apareixen. I tens gravat que tot arriba com a text, amb '4' > 40 i '10' > '9' com a recordatoris de per què una funció de normalització amb Number() no és opcional.
Domines el submit amb el seu preventDefault() obligatori i els seus detalls —es dispara amb Enter, els botons són submit per defecte, esdeveniment.submitter diu quin s'ha premut— i les dues formes de validar, que no competeixen sinó que s'apilen. La nativa aporta checkValidity, reportValidity, l'objecte validity amb una bandera per tipus de fallada, setCustomValidity (i l'obligació de netejar-lo), novalidate per quedar-te amb l'API sense els globus del navegador, i :user-invalid en lloc de :invalid per no tenyir la pàgina de vermell abans que ningú escrigui. La de JavaScript aporta el control del missatge, del moment i de l'accessibilitat, i les regles entre camps. I per damunt de totes dues hi ha la decisió d'arquitectura que ordena tot el projecte: les regles de negoci viuen al model. La vista no reimplementa R1, R2, R3 ni R9; intenta construir la Tasca, captura l'ErrorDeValidacio i fa servir el seu camp i el seu message per saber on posar el missatge i a quin camp moure el focus. Dissenyar aquell error amb un camp camp, dos mòduls abans que existís una pantalla, és el que fa que avui encaixi sense una línia de cola.
Saps mostrar errors que existeixen per a tothom: aria-invalid="true" al camp, el missatge enllaçat amb aria-describedby —conservant els ids que ja hi hagués—, un resum amb role="alert" que s'anuncia sol, el focus mogut al primer camp amb error, i una senyal que no depengui únicament del color. Saps validar al vol en el moment correcte —en abandonar un camp la primera vegada, i a cada tecla només per treure errors ja mostrats— i esmorteir el soroll amb un debounce construït amb un closure de 03-04, distingint-lo del throttle i tenint en compte que un aria-live sense esmorteir converteix un lector de pantalla en una metralladora. I tanques el cicle amb els tres gestos finals: reset(), focus al primer camp i confirmació perceptible.
Amb això acaba el Mòdul 6. Has recorregut el DOM sencer: què és l'arbre i com el construeix el navegador; com se selecciona amb selectors CSS i com es manipulen text, atributs, dataset, classes i estils; com es registren gestors i què porta l'objecte Event; com viatja un esdeveniment per les tres fases i com la delegació amb closest() i data-id permet atendre tota una llista amb un únic gestor; com es creen, insereixen i eliminen nodes sense obrir forats de seguretat ni fuites de memòria; com s'organitza el cicle estat → render → esdeveniment → estat nou → render amb reconciliació per claus; i com es recullen i validen dades de l'usuari. El model dels Mòduls 1 a 5 no ha canviat ni una línia en el procés: continua sent JavaScript pur, sense ni una menció a document. Aquella frontera és el que farà possible el Mòdul 8, quan provis el model sense navegador.
I ara obre les DevTools, crea tres tasques per a l'Iván, marca'n dues com a fetes, filtra per la Lucía… i prem F5. Tot desapareix. Tornen les sis tasques de dades/backlog.js, les 48 h, l'esforç 124, com si la Marta no hagués treballat. L'aplicació es veu preciosa i no recorda res, perquè tot el que ha passat viu a la memòria d'una pestanya que acabes de recarregar. Encara pitjor: encara que ho recordés, la Marta, l'Iván i la Lucía treballarien cadascun amb la seva pròpia còpia, sense manera de veure el que fan els altres. Falten les dues meitats que converteixen una pàgina en un producte: desar les dades al navegador perquè sobrevisquin a la recàrrega, i parlar amb un servidor perquè el tauler sigui el mateix per a tot l'equip. Això és el Mòdul 7: APIs del Navegador, que comença amb Emmagatzematge Local i de Sessió —on el toJSON i el static desDeJSON que vas escriure a 05-03 demostraran per fi per a què eren allà—.
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
