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

  1. El formulari d'alta: HTML semàntic
  2. Els atributs dels camps
  3. Llegir valors: value, FormData i Object.fromEntries
  4. Tot arriba com a text
  5. L'esdeveniment submit i preventDefault
  6. Validació nativa d'HTML5
  7. Validació nativa davant de validació en JavaScript
  8. Les regles de negoci viuen al model
  9. Mostrar errors de manera accessible
  10. Validació al vol amb input i debounce
  11. Enviar, reiniciar i tornar el focus
  12. Nómada Tasques: vista/formulari.js
  13. Errors Habituals i Consells
  14. Exercicis
  15. Conclusió

  1. 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'id del 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. Un placeholder no substitueix una etiqueta: desapareix en escriure i molts lectors no l'anuncien.
  • name a cada control. És la clau amb què la dada apareixerà a FormData. Sense name, el camp simplement no s'envia. I aquí els hem fet coincidir amb els noms de les propietats de Tasca (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-describedby connecta 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.
  • novalidate al <form> desactiva els globus d'error del navegador, però no desactiva l'API de validació: continuem podent consultar checkValidity(). É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.

  1. 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 que input.value retorni cadena buida si el contingut no és un número vàlid. És un comportament que sorprèn: el camp sembla que té text i value està buit.
  • disabled exclou el camp de l'enviament; readonly l'hi inclou. Si necessites mostrar un valor fix i que arribi a l'enviament, readonly.

  1. Llegir valors: value, FormData i Object.fromEntries

Hi 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 name requereix dades.getAll('etiquetes'), que retorna un array amb tots els valors.
  • Les caselles sense marcar no apareixen a FormData. No valen false: senzillament no hi són. Cal donar per fet que hi faltaran, amb ?? false o consultant la propietat checked.

  1. 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 malament

Aquell ú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.

  1. L'esdeveniment submit i preventDefault

L'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 click al botó en lloc de submit al formulari.
  • <button> dins d'un formulari és type="submit" per defecte. Un botó que fa una altra cosa necessita type="button" explícit, o enviarà el formulari sense voler.
  • esdeveniment.submitter indica quin botó ha provocat l'enviament, útil si n'hi ha diversos («Crear» i «Crear i duplicar»).
  • type="reset" dispara un esdeveniment reset i buida els camps; també és cancel·lable.

  1. 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 navegador

I 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 vegada

Regla 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.

  1. 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 No
Control del missatge Limitat (idioma del navegador) Total
Control del disseny i del moment Escàs Total
Regles entre diversos camps No pot
Regles de negoci (unicitat, quotes) No pot
Accessibilitat Bona per defecte S'ha de construir

La resposta correcta no és triar-ne una: és fer servir totes dues en capes.

  1. 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.
  2. Capa JavaScript de la vista: missatges propis, moment de mostrar-los, accessibilitat, regles entre camps.
  3. 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.

  1. 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.afegir rebutja ids duplicats.
  • R2: el constructor llança ErrorDeValidacio si el títol està buit.
  • R3: el setter d'horesEstimades exigeix 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 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.

  1. 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:

  1. Estar associat al camp, amb aria-describedby, perquè es llegeixi juntament amb ell.
  2. Marcar el camp com a invàlid, amb aria-invalid="true", perquè s'anunciï com a tal.
  3. Anunciar-se en aparèixer, amb role="alert" al resum general.
  4. 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
}

  1. Validació al vol amb input i debounce

Validar 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".

  1. 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 visible
  • reset() 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.

  1. Nómada Tasques: vista/formulari.js

El 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() al submit. 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 click al botó en lloc de submit al formulari. Trenca l'enviament amb Enter, que molts usuaris donen per fet.
  • Posar <button> sense type per a accions que no envien. Dins d'un formulari, el valor per defecte és submit. Qualsevol botó auxiliar necessita type="button".
  • Fer servir placeholder en lloc de <label>. Desapareix en escriure, no sempre s'anuncia i no s'hi pot fer clic. L'etiqueta és obligatòria; el placeholder és un extra.
  • No convertir els valors. Tot arriba com a text: '4' > 40 dona false per casualitat i '10' > '9' dona false per comparació de cadenes. Converteix amb Number() 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 Tasca ja valida el rang d'hores, la vista no ho ha de repetir: ha de capturar l'ErrorDeValidacio i fer servir el seu camp i el seu message. Una regla, un lloc.
  • Marcar-ho tot en vermell en carregar la pàgina. És el que fa :invalid a 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 name amb 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

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