El tablero de Nómada Tareas ya se dibuja solo, se filtra, se ordena y responde a los clics. Pero Marta sigue sin poder hacer lo más elemental: crear una tarea. El backlog es el de datos/backlog.js y punto. Dar de alta una tarea significa un formulario, y un formulario es mucho más que unos cuantos <input>: es el punto donde entran datos que no controlas, donde todo llega como texto, donde hay que decidir qué se valida en el navegador y qué en el modelo, y donde la accesibilidad deja de ser un detalle para volverse imprescindible —un mensaje de error que solo se ve en rojo no existe para quien no ve la pantalla—. En esta lección construirás el formulario de alta completo, con validación en dos capas, mensajes accesibles, validación al vuelo con debounce y gestión correcta del foco. Es la última pieza del Módulo 6.

Contenido

  1. El formulario de alta: HTML semántico
  2. Los atributos de los campos
  3. Leer valores: value, FormData y Object.fromEntries
  4. Todo llega como texto
  5. El evento submit y preventDefault
  6. Validación nativa de HTML5
  7. Validación nativa frente a validación en JavaScript
  8. Las reglas de negocio viven en el modelo
  9. Mostrar errores de forma accesible
  10. Validación al vuelo con input y debounce
  11. Enviar, resetear y devolver el foco
  12. Nómada Tareas: vista/formulario.js
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. El formulario de alta: HTML semántico

Empezamos por el marcado, porque un formulario bien escrito resuelve la mitad del trabajo antes de tocar JavaScript:

<section aria-labelledby="titulo-nueva">
  <h2 id="titulo-nueva">Nueva tarea</h2>

  <form id="nueva-tarea" class="formulario" novalidate>
    <p id="errores-form" class="errores" role="alert" hidden></p>

    <fieldset>
      <legend>Datos de la tarea</legend>

      <div class="campo">
        <label for="titulo">Título <span aria-hidden="true">*</span></label>
        <input type="text" id="titulo" name="titulo" required
               minlength="3" maxlength="80" autocomplete="off"
               aria-describedby="ayuda-titulo">
        <small id="ayuda-titulo" class="ayuda">Entre 3 y 80 caracteres.</small>
      </div>

      <div class="campo">
        <label for="responsable">Responsable</label>
        <select id="responsable" name="responsable">
          <option value="">Sin asignar</option>
          <option value="Marta">Marta</option>
          <option value="Iván">Iván</option>
          <option value="Lucía">Lucía</option>
        </select>
      </div>

      <div class="campo">
        <label for="prioridad">Prioridad</label>
        <select id="prioridad" name="prioridad">
          <option value="alta">Alta</option>
          <option value="media" selected>Media</option>
          <option value="baja">Baja</option>
        </select>
      </div>

      <div class="campo">
        <label for="horasEstimadas">Horas estimadas <span aria-hidden="true">*</span></label>
        <input type="number" id="horasEstimadas" name="horasEstimadas"
               required min="1" max="40" step="1" inputmode="numeric"
               aria-describedby="ayuda-horas">
        <small id="ayuda-horas" class="ayuda">Entre 1 y 40 horas (regla R3).</small>
      </div>

      <div class="campo">
        <label for="fechaLimite">Fecha límite <span aria-hidden="true">*</span></label>
        <input type="date" id="fechaLimite" name="fechaLimite" required min="2026-09-20">
      </div>

      <div class="campo">
        <label for="etiquetas">Etiquetas</label>
        <input type="text" id="etiquetas" name="etiquetas"
               pattern="[a-zA-ZáéíóúñÁÉÍÓÚÑ0-9, ]*" aria-describedby="ayuda-etiquetas">
        <small id="ayuda-etiquetas" class="ayuda">Separadas por comas: serigrafía, almacén</small>
      </div>

      <div class="campo">
        <label for="notas">Notas</label>
        <textarea id="notas" name="notas" rows="2" maxlength="200"></textarea>
      </div>
    </fieldset>

    <div class="acciones">
      <button type="submit">Crear tarea</button>
      <button type="reset">Limpiar</button>
    </div>
  </form>
</section>

Las decisiones que importan:

  • Cada campo tiene su <label for="…"> apuntando al id del control. Esto no es opcional: es lo que hace que un lector de pantalla anuncie «Título, campo de texto, requerido» y lo que permite activar el campo haciendo clic en la etiqueta. Un placeholder no sustituye a una etiqueta: desaparece al escribir y muchos lectores no lo anuncian.
  • name en cada control. Es la clave con la que el dato aparecerá en FormData. Sin name, el campo simplemente no se envía. Y aquí los hemos hecho coincidir con los nombres de las propiedades de Tarea (titulo, horasEstimadas, fechaLimite), lo que va a ahorrar mucho trabajo en el apartado 3.
  • <fieldset> con <legend> agrupa campos relacionados y les da un nombre común, que los lectores de pantalla anuncian al entrar en el grupo. Es imprescindible con grupos de radios y muy recomendable en general.
  • aria-describedby conecta el campo con su texto de ayuda, de modo que se lee junto al nombre del campo.
  • role="alert" en el contenedor de errores: cuando le pongas texto, los lectores de pantalla lo anunciarán inmediatamente, sin que el usuario tenga que ir a buscarlo.
  • novalidate en el <form> desactiva los globos de error del navegador, pero no desactiva la API de validación: seguimos pudiendo consultar checkValidity(). Es lo que nos permitirá mostrar los errores con nuestro propio diseño y con la accesibilidad que queramos. Volveremos sobre ello en el apartado 6.

  1. Los atributos de los campos

El HTML moderno tiene mucha más capacidad de validación de la que se suele usar:

Atributo Se aplica a Qué hace
required Casi todos El campo no puede quedar vacío
minlength / maxlength text, textarea, search Longitud del texto (maxlength además impide escribir más)
min / max number, date, range Valor mínimo y máximo
step number, date, range Incremento permitido (step="1" = solo enteros)
pattern text, tel, search Expresión regular que el valor debe cumplir
type Todos Valida el formato: email, url, number, date
autocomplete Casi todos Sugerencias del navegador (off para campos únicos)
inputmode Texto y número Qué teclado muestra el móvil (numeric, decimal, tel)
readonly / disabled Casi todos Solo lectura / desactivado (disabled no se envía)

Dos detalles que se olvidan a menudo:

  • type="number" no impide escribir letras en todos los navegadores, pero sí hace que input.value devuelva cadena vacía si el contenido no es un número válido. Es un comportamiento que sorprende: el campo parece tener texto y value está vacío.
  • disabled excluye el campo del envío; readonly lo incluye. Si necesitas mostrar un valor fijo y que llegue en el envío, readonly.

  1. Leer valores: value, FormData y Object.fromEntries

Hay tres niveles, de más manual a más automático.

Nivel 1: campo a campo.

const formulario = document.querySelector('#nueva-tarea');
const titulo = formulario.querySelector('#titulo').value;
const horas  = formulario.querySelector('#horasEstimadas').value;

Funciona, pero cada campo nuevo son dos líneas más y un id que puede quedar desactualizado.

Nivel 2: form.elements. Todo formulario tiene una colección de sus controles, accesible por name:

console.log(formulario.elements.titulo.value);        // por nombre
console.log(formulario.elements['horasEstimadas'].value);
console.log(formulario.elements.length);              // número de controles

Es cómoda para acceder a un control concreto sin buscarlo con querySelector.

Nivel 3: FormData + Object.fromEntries. Esta es la forma idiomática:

const datos = new FormData(formulario);

// FormData es iterable: pares [nombre, valor]
for (const [clave, valor] of datos) {
  console.log(clave, '=', JSON.stringify(valor));
}
// titulo = "Reponer tinta blanca"
// responsable = "Lucía"
// prioridad = "alta"
// horasEstimadas = "4"
// fechaLimite = "2026-10-10"
// etiquetas = "serigrafía, almacén"
// notas = ""

// Y con Object.fromEntries (04-05), un objeto plano de una sola línea:
const bruto = Object.fromEntries(datos);
console.log(bruto);
// { titulo: 'Reponer tinta blanca', responsable: 'Lucía', prioridad: 'alta',
//   horasEstimadas: '4', fechaLimite: '2026-10-10', etiquetas: 'serigrafía, almacén', notas: '' }

Aquí se paga el dividendo de haber puesto name="horasEstimadas" en lugar de name="horas": las claves del objeto ya coinciden con las propiedades que espera el constructor de Tarea.

Dos limitaciones de Object.fromEntries que conviene conocer:

  • Con nombres repetidos, se queda con el último. Un grupo de casillas con el mismo name requiere datos.getAll('etiquetas'), que devuelve un array con todos los valores.
  • Los checkbox sin marcar no aparecen en FormData. No valen false: sencillamente no están. Hay que dar por hecho su ausencia con ?? false o consultar la propiedad checked.

  1. Todo llega como texto

Mira otra vez la salida de arriba: horasEstimadas vale '4', la cadena, no el número. Es la misma frontera que ya viste con dataset en 06-02 y la razón de ser de la lección 01-07.

const bruto = Object.fromEntries(new FormData(formulario));

console.log(typeof bruto.horasEstimadas);        // 'string'
console.log(bruto.horasEstimadas + 1);           // '41'  ← concatenación
console.log(bruto.horasEstimadas > 40);          // false  ← '4' > 40 compara mal

Ese último caso es especialmente traicionero: '4' > 40 se convierte a 4 > 40 y da false por casualidad; pero '9' > 40 también da false, y '10' > '9' da false porque compara cadenas. Cualquier validación numérica sobre texto sin convertir es una bomba de relojería.

La solución es una función de conversión de frontera explícita, con Number (nunca parseInt, que acepta '12abc' y devuelve 12):

/** Convierte los datos crudos del formulario a los tipos del modelo. */
function normalizar(bruto) {
  return {
    titulo: bruto.titulo.trim(),
    responsable: bruto.responsable === '' ? null : bruto.responsable,   // R8
    prioridad: bruto.prioridad,
    horasEstimadas: Number(bruto.horasEstimadas),                        // '4' → 4
    fechaLimite: bruto.fechaLimite,                                      // ya es 'yyyy-mm-dd'
    etiquetas: bruto.etiquetas
      .split(',')
      .map((e) => e.trim().toLowerCase())
      .filter((e) => e !== ''),                                          // R9
    notas: bruto.notas.trim()
  };
}

console.log(normalizar(bruto));
// { titulo: 'Reponer tinta blanca', responsable: 'Lucía', prioridad: 'alta',
//   horasEstimadas: 4, fechaLimite: '2026-10-10',
//   etiquetas: ['serigrafía', 'almacén'], notas: '' }

Un aviso sobre Number con la cadena vacía: Number('') es 0, no NaN. Si un campo numérico obligatorio se envía vacío y el modelo espera un número, 0 pasaría la conversión y fallaría después con un mensaje confuso. Por eso el required del HTML y la validación del modelo se complementan.

Y una nota sobre type="date": su value es siempre una cadena 'yyyy-mm-dd', exactamente el formato ISO que usa Nómada Tareas. Existe además input.valueAsNumber (milisegundos) y valueAsDate (un objeto Date), pero para nosotros la cadena es lo idóneo, porque es lo que guarda el modelo.

  1. El evento submit y preventDefault

El envío de un formulario se escucha en el <form>, no en el botón:

formulario.addEventListener('submit', (evento) => {
  evento.preventDefault();     // ← sin esto, la página se recarga y pierdes todo
  // … procesar
});

Sin preventDefault(), el navegador hace lo que hacía en 1995: serializa el formulario, navega a la URL del atributo action (o recarga la actual) y tu aplicación entera vuelve a empezar. Es el error número uno con formularios y produce el síntoma desconcertante de «la página parpadea y no pasa nada».

Detalles útiles sobre el submit:

  • Se dispara con Enter dentro de un campo de texto, no solo al pulsar el botón. Es una comodidad esperada por los usuarios: no la rompas escuchando click en el botón en lugar de submit en el formulario.
  • <button> dentro de un formulario es type="submit" por defecto. Un botón que hace otra cosa necesita type="button" explícito, o enviará el formulario sin querer.
  • evento.submitter indica qué botón provocó el envío, útil si hay varios («Crear» y «Crear y duplicar»).
  • type="reset" dispara un evento reset y vacía los campos; también es cancelable.

  1. Validación nativa de HTML5

El navegador trae un motor de validación completo, y desperdiciarlo es un error frecuente. Cada control tiene:

const campo = formulario.elements.horasEstimadas;

console.log(campo.validity);
// ValidityState { valueMissing: false, rangeOverflow: true, badInput: false, valid: false, … }

console.log(campo.checkValidity());     // false · ¿es válido? (dispara un evento 'invalid')
console.log(campo.validationMessage);   // 'El valor debe ser menor o igual que 40.'
console.log(campo.willValidate);        // true · ¿participa en la validación?

El objeto validity tiene una bandera por tipo de fallo, y esto permite mensajes precisos:

Bandera Se activa cuando
valueMissing Es required y está vacío
typeMismatch No cumple el type (email, url)
patternMismatch No cumple el pattern
tooShort / tooLong Incumple minlength / maxlength
rangeUnderflow / rangeOverflow Incumple min / max
stepMismatch No encaja con el step
badInput El navegador no puede interpretar lo escrito (letras en type="number")
customError Tú has llamado a setCustomValidity
valid Ninguna de las anteriores

A nivel de formulario:

formulario.checkValidity();     // ¿son válidos TODOS los campos? (sin mostrar nada)
formulario.reportValidity();    // igual, pero además MUESTRA los globos del navegador

Y setCustomValidity permite añadir un error propio al motor nativo:

const fecha = formulario.elements.fechaLimite;

fecha.setCustomValidity('La fecha límite no puede ser anterior a hoy.');
console.log(fecha.validity.customError);   // true
console.log(fecha.checkValidity());        // false

fecha.setCustomValidity('');               // ← cadena vacía = campo válido otra vez

Regla de oro con setCustomValidity: hay que limpiarlo. Si pones un mensaje y no lo borras cuando el usuario corrige, el campo queda inválido para siempre y el formulario no se enviará nunca, sin ninguna pista de por qué.

En CSS puedes reaccionar a la validez con pseudoclases:

.campo input:user-invalid,
.campo select:user-invalid { border-color: var(--color-alta); }

.campo input:user-valid { border-color: var(--color-baja); }

:user-invalid es preferible a :invalid porque solo se activa después de que el usuario haya interactuado con el campo. Con :invalid a secas, todos los campos obligatorios aparecen en rojo nada más cargar la página, antes de que nadie haya escrito nada: una experiencia hostil.

  1. Validación nativa frente a validación en JavaScript

Aspecto Validación nativa (HTML5) Validación en JavaScript
Esfuerzo Un atributo Código a escribir y mantener
Funciona sin JS No
Control del mensaje Limitado (idioma del navegador) Total
Control del diseño y el momento Escaso Total
Reglas entre varios campos No puede
Reglas de negocio (unicidad, cupos) No puede
Accesibilidad Buena por defecto Hay que construirla

La respuesta correcta no es elegir una: es usar las dos en capas.

  1. Capa HTML: required, min, max, pattern, type. Barata, funciona sin JavaScript, guía al usuario mientras escribe y hace que el móvil muestre el teclado adecuado.
  2. Capa JavaScript de la vista: mensajes propios, momento de mostrarlos, accesibilidad, reglas entre campos.
  3. Capa del modelo: las reglas de negocio de verdad, que ya existen desde el Módulo 5 y que no se duplican.

Y una cuarta capa que no es opcional en una aplicación real: el servidor. Toda la validación del navegador se puede saltar con las DevTools en diez segundos. Sirve para ayudar al usuario, nunca para garantizar la integridad de los datos. Nómada Tareas todavía no tiene servidor; lo tendrá en el Módulo 7.

  1. Las reglas de negocio viven en el modelo

Esta es la decisión de arquitectura más importante de la lección. Mira lo que ya sabe hacer Tarea desde 05-02 y 05-03:

  • R1: Tablero.agregar rechaza ids duplicados.
  • R2: el constructor lanza ErrorDeValidacion si el título está vacío.
  • R3: el setter de horasEstimadas exige un número entre 1 y 40.
  • R5: el estado inicial es 'pendiente'.
  • R9: las etiquetas se normalizan y se deduplican.

Sería un error reescribir todo eso en la vista. Si lo hicieras, tendrías dos copias de cada regla, que se desincronizarían a la primera modificación, y el Módulo 8 tendría que probarlas dos veces.

Lo correcto es intentar construir la tarea y capturar el error. La vista no valida reglas de negocio: se limita a traducir el error del modelo en un mensaje en pantalla.

import { Tarea } from '../modelo/tarea.js';
import { ErrorDeValidacion } from '../modelo/errores.js';

function intentarCrear(datos, tablero, siguienteId) {
  try {
    const tarea = new Tarea({ ...datos, id: siguienteId() });   // R2, R3, R5, R9
    tablero.agregar(tarea);                                     // R1
    return { ok: true, tarea };
  } catch (error) {
    if (error instanceof ErrorDeValidacion) {
      return { ok: false, campo: error.campo, mensaje: error.message };
    }
    throw error;      // cualquier otro error es un fallo real: que suba (02-05)
  }
}

Ese error.campo es oro: el ErrorDeValidacion que diseñaste en 05-02 ya lleva el nombre del campo que falló, así que la vista sabe exactamente dónde poner el mensaje y a qué campo mover el foco. Diseñar así los errores, meses antes de tener una interfaz, es lo que hace que ahora encajen sin esfuerzo.

Los ids los genera el closure de 03-04, que sigue siendo la forma correcta de no exponer un contador global:

// js/util/ids.js
export function crearGeneradorDeIds(inicial = 1) {
  let siguiente = inicial;
  return () => siguiente++;
}
// js/app.js
import { crearGeneradorDeIds } from './util/ids.js';
const siguienteId = crearGeneradorDeIds(
  Math.max(...tablero.tareas.map((t) => t.id)) + 1     // 7, con el backlog canónico
);

Quedan las validaciones que pertenecen a la vista, porque no son reglas del dominio sino de este formulario concreto: que la fecha límite no sea anterior a hoy, que el campo de etiquetas no traiga más de cinco, o cualquier comprobación que dependa de la relación entre dos campos.

  1. Mostrar errores de forma accesible

Un mensaje de error tiene que cumplir cuatro cosas. Si falla una, hay gente que no puede usar tu formulario:

  1. Estar asociado al campo, con aria-describedby, para que se lea junto a él.
  2. Marcar el campo como inválido, con aria-invalid="true", para que se anuncie como tal.
  3. Anunciarse al aparecer, con role="alert" en el resumen general.
  4. Mover el foco al primer campo con error, para que quien navega con teclado no tenga que buscarlo.

Y un quinto requisito que no es técnico: el color no puede ser la única señal. Un borde rojo sin texto no dice nada a quien no distingue el rojo.

// js/vista/formulario.js (fragmento)
import { $ } from './dom.js';

/** Marca un campo como erróneo y muestra su mensaje. */
function marcarError(campo, mensaje) {
  campo.setAttribute('aria-invalid', 'true');

  const idError = `error-${campo.name}`;
  let error = document.getElementById(idError);

  if (error === null) {
    error = document.createElement('small');
    error.id = idError;
    error.className = 'campo__error';
    campo.closest('.campo').append(error);
  }
  error.textContent = mensaje;

  // Añadimos el id del error a aria-describedby SIN borrar el de la ayuda
  const descritoPor = (campo.getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
  if (!descritoPor.includes(idError)) {
    campo.setAttribute('aria-describedby', [...descritoPor, idError].join(' '));
  }
}

/** Limpia el error de un campo. */
function limpiarError(campo) {
  campo.removeAttribute('aria-invalid');
  campo.setCustomValidity('');                       // ← imprescindible
  document.getElementById(`error-${campo.name}`)?.remove();

  const descritoPor = (campo.getAttribute('aria-describedby') ?? '')
    .split(' ').filter((id) => id !== `error-${campo.name}` && id !== '');
  if (descritoPor.length > 0) campo.setAttribute('aria-describedby', descritoPor.join(' '));
  else campo.removeAttribute('aria-describedby');
}
.campo__error { color: var(--color-alta); font-weight: 600; display: block; }
.campo__error::before { content: "⚠ "; }        /* señal no cromática */
[aria-invalid="true"] { border: 2px solid var(--color-alta); }
.errores { color: var(--color-alta); font-weight: 600; }

Fíjate en el cuidado con aria-describedby: puede contener varios ids separados por espacios, y el campo ya tenía el de su texto de ayuda. Machacarlo con setAttribute('aria-describedby', idError) haría desaparecer la ayuda. Es un detalle pequeño con consecuencias reales.

Y el movimiento del foco, que se hace una sola vez, al primer error:

function mostrarErrores(formulario, errores) {
  const resumen = $('#errores-form');

  if (errores.length === 0) {
    resumen.hidden = true;
    resumen.textContent = '';
    return;
  }

  resumen.textContent = errores.length === 1
    ? errores[0].mensaje
    : `Hay ${errores.length} campos con errores. Revísalos antes de continuar.`;
  resumen.hidden = false;                     // role="alert" lo anuncia al mostrarse

  for (const { campo, mensaje } of errores) marcarError(campo, mensaje);
  errores[0].campo.focus();                   // ← el foco al PRIMER error
}

  1. Validación al vuelo con input y debounce

Validar solo al enviar es frustrante: el usuario rellena siete campos y descubre al final que el segundo estaba mal. Validar en cada tecla es igual de molesto: aparece «el título es demasiado corto» al escribir la primera letra.

El equilibrio que funciona:

Momento Qué validar
blur / focusout El campo que se acaba de abandonar (primera vez)
input Solo los campos ya marcados como erróneos, para quitar el error en cuanto se corrija
submit Todo

Y para las validaciones caras o ruidosas —una búsqueda de títulos duplicados, por ejemplo— se aplica un debounce: esperar a que el usuario deje de escribir durante un rato antes de actuar. Se implementa con el closure de 03-04:

// js/util/tiempo.js

/**
 * Devuelve una versión de `fn` que solo se ejecuta cuando han pasado
 * `espera` milisegundos desde la última llamada.
 */
export function debounce(fn, espera = 300) {
  let temporizador = null;              // ← vive en el closure, uno por función creada

  return function (...args) {
    clearTimeout(temporizador);         // cancela la ejecución pendiente
    temporizador = setTimeout(() => fn.apply(this, args), espera);
  };
}
const validarTitulo = debounce((campo) => {
  if (campo.value.trim().length < 3) marcarError(campo, 'Mínimo 3 caracteres.');
  else limpiarError(campo);
}, 300);

formulario.elements.titulo.addEventListener('input', (e) => validarTitulo(e.target));

Traza de lo que ocurre si Marta escribe «Reponer tinta» en un segundo:

R  → programa validación en 300 ms
e  → cancela la anterior, programa otra
p  → cancela, programa
…
a  → cancela, programa
(pausa de 300 ms)
→ se ejecuta UNA vez, con el valor final

Trece pulsaciones, una sola validación. Con datos locales el ahorro es simbólico; con una comprobación contra un servidor (Módulo 7) es la diferencia entre trece peticiones y una.

No confundas debounce con throttle: el primero espera a que paren los eventos; el segundo ejecuta como mucho una vez cada X milisegundos, y es lo apropiado para scroll o resize. Ambas técnicas se estudian con detalle en Optimización del Rendimiento.

Un aviso de accesibilidad sobre el debounce: con role="alert", cada cambio de texto se anuncia. Si validas al vuelo un campo mientras el usuario escribe, el lector de pantalla interrumpirá constantemente. Por eso los mensajes al vuelo van en el <small> del campo (con aria-describedby, que se lee cuando el usuario llega al campo) y solo el resumen del envío usa role="alert".

  1. Enviar, resetear y devolver el foco

Cuando el envío va bien, quedan tres gestos que separan un formulario cuidado de uno descuidado:

formulario.reset();                          // vacía los campos (respeta los 'selected')
formulario.elements.titulo.focus();          // devuelve el foco al primer campo
anunciar(`Tarea «${tarea.titulo}» creada.`); // confirmación audible y visible
  • reset() devuelve cada control a su valor inicial del HTML, no a la cadena vacía: por eso el <option value="media" selected> vuelve a quedar seleccionado. También limpia el estado :user-invalid.
  • Devolver el foco al primer campo permite crear varias tareas seguidas sin tocar el ratón. Si no lo haces, el foco se queda en el botón «Crear» y el usuario tiene que retroceder con Shift+Tab siete veces.
  • La confirmación debe ser perceptible por todos. Un texto que aparece en un contenedor con role="status" se anuncia sin interrumpir; una animación silenciosa no.

Recuerda también limpiar los errores anteriores en cada envío: si no lo haces, quedan marcados campos que ya se corrigieron.

  1. Nómada Tareas: vista/formulario.js

El módulo completo, que junta las tres capas de validación:

// js/vista/formulario.js
import { $, $$ } from './dom.js';
import { debounce } from '../util/tiempo.js';
import { Tarea } from '../modelo/tarea.js';
import { ErrorDeValidacion } from '../modelo/errores.js';
import { EVENTOS, emitir } from './eventos.js';
import { HOY } from '../util/fechas.js';

/** Convierte los datos crudos del formulario a los tipos del modelo. */
function normalizar(bruto) {
  return {
    titulo: (bruto.titulo ?? '').trim(),
    responsable: bruto.responsable === '' ? null : bruto.responsable,
    prioridad: bruto.prioridad,
    horasEstimadas: bruto.horasEstimadas === '' ? NaN : Number(bruto.horasEstimadas),
    fechaLimite: bruto.fechaLimite,
    etiquetas: (bruto.etiquetas ?? '')
      .split(',').map((e) => e.trim().toLowerCase()).filter(Boolean)
  };
}

/** Validaciones propias de ESTE formulario (no reglas de dominio). */
function validarFormulario(formulario, datos, hoy) {
  const errores = [];

  for (const campo of formulario.elements) {
    if (campo.willValidate && !campo.checkValidity()) {
      errores.push({ campo, mensaje: campo.validationMessage });   // capa nativa
    }
  }

  const fecha = formulario.elements.fechaLimite;
  if (errores.every((e) => e.campo !== fecha) && datos.fechaLimite < hoy) {
    errores.push({ campo: fecha, mensaje: 'La fecha límite no puede ser anterior a hoy.' });
  }

  if (datos.etiquetas.length > 5) {
    errores.push({ campo: formulario.elements.etiquetas, mensaje: 'Máximo 5 etiquetas.' });
  }

  return errores;
}

export function conectarFormulario({ formulario, tablero, siguienteId, alCrear,
                                     hoy = HOY, signal }) {

  const resumen = $('#errores-form');

  function limpiarTodo() {
    resumen.hidden = true;
    resumen.textContent = '';
    for (const campo of formulario.elements) {
      if (campo.name) limpiarError(campo);
    }
  }

  // ── 1 · Envío ────────────────────────────────────────────────────────────
  formulario.addEventListener('submit', (evento) => {
    evento.preventDefault();                       // imprescindible
    limpiarTodo();

    const datos = normalizar(Object.fromEntries(new FormData(formulario)));

    // Capa A · validación del formulario (nativa + reglas de la vista)
    const errores = validarFormulario(formulario, datos, hoy);
    if (errores.length > 0) { mostrarErrores(formulario, errores); return; }

    // Capa B · reglas de negocio: las aplica el MODELO, no la vista
    let tarea;
    try {
      tarea = new Tarea({ ...datos, id: siguienteId(), estado: 'pendiente' });  // R2, R3, R5, R9
      tablero.agregar(tarea);                                                   // R1
    } catch (error) {
      if (!(error instanceof ErrorDeValidacion)) throw error;
      const campo = formulario.elements[error.campo] ?? formulario.elements.titulo;
      mostrarErrores(formulario, [{ campo, mensaje: error.message }]);
      return;
    }

    // Éxito
    emitir(formulario, EVENTOS.TAREA_CREADA, { id: tarea.id, titulo: tarea.titulo });
    alCrear?.(tarea);                              // el llamador decide qué hacer (render)

    formulario.reset();
    formulario.elements.titulo.focus();
    resumen.hidden = false;
    resumen.textContent = `Tarea «${tarea.titulo}» creada con el identificador ${tarea.id}.`;
  }, { signal });

  // ── 2 · Validación al abandonar un campo ─────────────────────────────────
  formulario.addEventListener('focusout', (evento) => {     // focusin/focusout SÍ burbujean
    const campo = evento.target;
    if (!campo.name || !campo.willValidate) return;
    if (campo.value === '' && !campo.required) return;      // no molestar con campos opcionales
    if (campo.checkValidity()) limpiarError(campo);
    else marcarError(campo, campo.validationMessage);
  }, { signal });

  // ── 3 · Corrección al vuelo, con debounce ────────────────────────────────
  const revalidar = debounce((campo) => {
    if (campo.checkValidity()) limpiarError(campo);
  }, 300);

  formulario.addEventListener('input', (evento) => {
    const campo = evento.target;
    // Solo revalidamos lo que YA estaba marcado como erróneo
    if (campo.getAttribute('aria-invalid') === 'true') revalidar(campo);
  }, { signal });

  // ── 4 · Reset limpio ─────────────────────────────────────────────────────
  formulario.addEventListener('reset', () => {
    limpiarTodo();
    setTimeout(() => formulario.elements.titulo.focus(), 0);  // tras el reset del navegador
  }, { signal });
}

Y el cableado final en app.js, con el ciclo completo del módulo:

// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { crearGeneradorDeIds } from './util/ids.js';
import { HOY } from './util/fechas.js';
import { TableroVista } from './vista/tablero-vista.js';
import { conectarFormulario } from './vista/formulario.js';
import { $ } from './vista/dom.js';

const tablero = new Tablero('Taller Nómada', crearBacklog());
const siguienteId = crearGeneradorDeIds(Math.max(...tablero.tareas.map((t) => t.id)) + 1);

const vista = new TableroVista({
  contenedor: $('.tablero'), resumen: $('#resumen'), tablero, hoy: HOY
});
vista.render();

conectarFormulario({
  formulario: $('#nueva-tarea'),
  tablero,
  siguienteId,
  hoy: HOY,
  alCrear: () => vista.render()            // ← estado nuevo, render: el ciclo de 06-06
});

Pruébalo. Crea «Reponer tinta blanca», Lucía, prioridad alta, 4 h, 10 de octubre, etiquetas «serigrafía, almacén». La tarjeta aparece en la columna «Pendientes», el contador pasa de 3 tareas · 25 h a 4 tareas · 29 h, y el resumen de 6 tareas · 5 abiertas · 45 de 48 h a 7 tareas · 6 abiertas · 49 de 52 h. Prueba después a enviar con el título vacío: el resumen anuncia el error, el campo se marca con aria-invalid, aparece el mensaje bajo la etiqueta y el foco salta al campo. Y prueba a poner 60 horas: el max="40" del HTML lo detecta antes de llegar al modelo, y si lo saltaras con las DevTools, el setter de horasEstimadas (R3) lo rechazaría igualmente. Dos capas, la misma regla, una sola definición.

Errores Comunes y Consejos

  • Olvidar preventDefault() en el submit. La página se recarga y todo el estado se pierde. Si tu formulario «parpadea y no hace nada», este es el motivo el 90 % de las veces.
  • Escuchar click en el botón en vez de submit en el formulario. Rompe el envío con Enter, que muchos usuarios dan por hecho.
  • Poner <button> sin type para acciones que no envían. Dentro de un formulario, el valor por defecto es submit. Cualquier botón auxiliar necesita type="button".
  • Usar placeholder en lugar de <label>. Desaparece al escribir, no siempre se anuncia y no se puede pulsar. La etiqueta es obligatoria; el placeholder es un extra.
  • No convertir los valores. Todo llega como texto: '4' > 40 da false por casualidad y '10' > '9' da false por comparación de cadenas. Convierte con Number() en una función de normalización.
  • Olvidar setCustomValidity('') al corregir. El campo queda inválido para siempre y el formulario no se envía nunca, sin ninguna pista visible.
  • Machacar aria-describedby. Puede contener varios ids; sobrescribirlo elimina la referencia al texto de ayuda. Añade y quita ids conservando los demás.
  • Duplicar las reglas de negocio en la vista. Si Tarea ya valida el rango de horas, la vista no debe repetirlo: debe capturar el ErrorDeValidacion y usar su campo y su message. Una regla, un sitio.
  • Marcar todo en rojo al cargar la página. Es lo que hace :invalid a secas. Usa :user-invalid, que espera a que el usuario interactúe.
  • Confiar en la validación del cliente. Se salta con las DevTools en diez segundos. Es una ayuda al usuario, no una garantía; la garantía la da el servidor (Módulo 7).
  • Consejo: haz coincidir los name con las propiedades del modelo. Object.fromEntries(new FormData(form)) produce entonces un objeto casi listo para el constructor, y te ahorras un mapeo entero.
  • Consejo: prueba tu formulario solo con el teclado. Tab, Shift+Tab, Enter, Escape. Si puedes rellenarlo, enviarlo, corregir un error y volver a enviarlo sin tocar el ratón, está bien hecho.

Ejercicios

Ejercicio 1 · Revisor distinto del responsable

Añade al formulario un <select name="revisor"> con las mismas personas y una validación entre campos: el revisor no puede ser la misma persona que el responsable. Debe mostrarse como error accesible en el campo del revisor, revalidarse al cambiar cualquiera de los dos, y no impedir que ambos queden sin asignar.

Ejercicio 2 · Contador de caracteres accesible

Añade bajo el campo de notas un contador «120 / 200 caracteres» que se actualice al escribir y avise cuando queden menos de 20. Debe usar input, no interrumpir a los lectores de pantalla en cada tecla, y aplicar un debounce al anuncio. Explica qué combinación de aria-live y debounce elegiste y por qué.

Ejercicio 3 · Duplicados por título

Impide crear dos tareas abiertas con el mismo título (comparando sin distinguir mayúsculas ni acentos sobrantes). Decide razonadamente en qué capa vive esta regla, impleméntala ahí, y valida al vuelo con debounce de 400 ms mostrando el aviso antes de que el usuario pulse «Crear tarea».

Soluciones

Ejercicio 1

<div class="campo">
  <label for="revisor">Revisor</label>
  <select id="revisor" name="revisor">
    <option value="">Sin 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(formulario) {
  const revisor = formulario.elements.revisor;
  const responsable = formulario.elements.responsable;

  const choca = revisor.value !== '' && revisor.value === responsable.value;
  revisor.setCustomValidity(choca ? 'El revisor no puede ser el responsable.' : '');

  if (choca) marcarError(revisor, revisor.validationMessage);
  else limpiarError(revisor);
  return !choca;
}

// Se revalida al cambiar CUALQUIERA de los dos
for (const nombre of ['revisor', 'responsable']) {
  formulario.elements[nombre].addEventListener('change', () => validarRevisor(formulario));
}

Dos decisiones importantes. La primera: se usa setCustomValidity, lo que integra la regla en el motor nativo y hace que el bucle checkValidity() del envío la detecte sin código adicional. La segunda: se escucha en los dos campos, porque el error puede aparecer o desaparecer al cambiar cualquiera de ellos; validar solo el revisor dejaría un error obsoleto si el usuario corrige cambiando el responsable. Y el revisor.value !== '' permite que ambos queden vacíos, tal como pedía el enunciado: «sin asignar» y «sin revisor» no chocan entre sí.

Ejercicio 2

<small id="contador-notas" class="ayuda" aria-live="polite">0 / 200 caracteres</small>
<textarea id="notas" name="notas" rows="2" maxlength="200"
          aria-describedby="contador-notas"></textarea>
const notas = formulario.elements.notas;
const contador = $('#contador-notas');

// Actualización VISUAL inmediata, sin aria-live activo
const anunciar = debounce((texto) => { contador.textContent = texto; }, 500);

notas.addEventListener('input', () => {
  const usados = notas.value.length;
  const restantes = 200 - usados;
  const texto = `${usados} / 200 caracteres`;

  contador.classList.toggle('ayuda--aviso', restantes < 20);
  anunciar(restantes < 20 ? `${texto}. Quedan ${restantes}.` : texto);
});

La combinación elegida es aria-live="polite" más un debounce de 500 ms, y el razonamiento es este: polite hace que el lector espere a terminar lo que esté diciendo antes de anunciar el cambio, en lugar de interrumpir como haría assertive o role="alert". Pero incluso siendo polite, actualizar el texto en cada tecla encolaría decenas de anuncios. El debounce garantiza que solo se anuncia cuando el usuario hace una pausa, que es justo el momento en que la información le resulta útil. La clase de aviso, en cambio, se aplica de inmediato: es información visual, no cuesta nada y no interrumpe a nadie.

Ejercicio 3

La regla «no puede haber dos tareas abiertas con el mismo título» es una regla de negocio, no de presentación: sigue siendo cierta aunque la tarea se cree desde un script, desde una importación o desde el servidor. Por tanto vive en el modelo, junto a R1.

// modelo/tablero.js
#normalizarTitulo(titulo) {
  return titulo.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

/** ¿Hay ya una tarea ABIERTA con este título? (R11) */
tituloDuplicado(titulo, exceptoId = null) {
  const buscado = this.#normalizarTitulo(titulo);
  return this.#tareas.some(
    (t) => t.abierta && t.id !== exceptoId && this.#normalizarTitulo(t.titulo) === buscado
  );
}

agregar(tarea) {
  // … R1 …
  if (this.tituloDuplicado(tarea.titulo)) {
    throw new ErrorDeValidacion(
      `Ya existe una tarea abierta titulada «${tarea.titulo}».`, 'titulo', tarea.titulo);
  }
  this.#tareas.push(tarea);
  return this;
}
// vista/formulario.js — aviso al vuelo, sin duplicar la regla
const avisarDuplicado = debounce((campo) => {
  const duplicado = tablero.tituloDuplicado(campo.value);
  campo.setCustomValidity(duplicado ? 'Ya existe una tarea abierta con ese título.' : '');
  if (duplicado) marcarError(campo, campo.validationMessage);
  else limpiarError(campo);
}, 400);

formulario.elements.titulo.addEventListener('input', (e) => avisarDuplicado(e.target));

La clave está en que la vista pregunta al modelo (tablero.tituloDuplicado(...)) en lugar de reimplementar la comparación. Si mañana se decide que también cuentan las tareas hechas, o que hay que ignorar la puntuación, se cambia una única función y tanto el aviso al vuelo como el rechazo del envío se comportan igual. El normalize('NFD') seguido de la eliminación de los diacríticos es la forma estándar de comparar «serigrafía» y «serigrafia» como iguales.

Conclusión

Con esto Nómada Tareas está completa como aplicación de navegador. Sabes escribir un formulario semántico: cada control con su <label for>, su name —haciéndolo coincidir con las propiedades del modelo, que ahorra un mapeo entero—, sus <fieldset> y <legend> para agrupar, sus aria-describedby para las ayudas y sus atributos de validación (required, minlength, min, max, step, pattern, type="date", type="number"). Sabes leer los valores en tres niveles, del elemento.value a la forma idiomática Object.fromEntries(new FormData(formulario)), con las dos limitaciones que conviene recordar: los nombres repetidos requieren getAll, y los checkbox sin marcar sencillamente no aparecen. Y tienes grabado que todo llega como texto, con '4' > 40 y '10' > '9' como recordatorios de por qué una función de normalización con Number() no es opcional.

Dominas el submit con su preventDefault() obligatorio y sus detalles —se dispara con Enter, los botones son submit por defecto, evento.submitter dice cuál se pulsó— y las dos formas de validar, que no compiten sino que se apilan. La nativa aporta checkValidity, reportValidity, el objeto validity con una bandera por tipo de fallo, setCustomValidity (y la obligación de limpiarlo), novalidate para quedarte con la API sin los globos del navegador, y :user-invalid en lugar de :invalid para no teñir la página de rojo antes de que nadie escriba. La de JavaScript aporta el control del mensaje, del momento y de la accesibilidad, y las reglas entre campos. Y por encima de ambas está la decisión de arquitectura que ordena todo el proyecto: las reglas de negocio viven en el modelo. La vista no reimplementa R1, R2, R3 ni R9; intenta construir la Tarea, captura el ErrorDeValidacion y usa su campo y su message para saber dónde poner el mensaje y a qué campo mover el foco. Diseñar aquel error con un campo campo, dos módulos antes de que existiera una pantalla, es lo que hace que hoy encaje sin una línea de pegamento.

Sabes mostrar errores que existen para todo el mundo: aria-invalid="true" en el campo, el mensaje enlazado con aria-describedby —conservando los ids que ya hubiera—, un resumen con role="alert" que se anuncia solo, el foco movido al primer campo con error, y una señal que no dependa únicamente del color. Sabes validar al vuelo en el momento correcto —al abandonar un campo la primera vez, y en cada tecla solo para quitar errores ya mostrados— y amortiguar el ruido con un debounce construido con un closure de 03-04, distinguiéndolo del throttle y teniendo en cuenta que un aria-live sin amortiguar convierte a un lector de pantalla en una ametralladora. Y cierras el ciclo con los tres gestos finales: reset(), foco al primer campo y confirmación perceptible.

Con esto termina el Módulo 6. Has recorrido el DOM entero: qué es el árbol y cómo lo construye el navegador; cómo se selecciona con selectores CSS y cómo se manipula texto, atributos, dataset, clases y estilos; cómo se registran manejadores y qué trae el objeto Event; cómo viaja un evento por las tres fases y cómo la delegación con closest() y data-id permite atender toda una lista con un único manejador; cómo se crean, insertan y eliminan nodos sin abrir agujeros de seguridad ni fugas de memoria; cómo se organiza el ciclo estado → render → evento → nuevo estado → render con reconciliación por claves; y cómo se recogen y validan datos del usuario. El modelo de los Módulos 1 a 5 no ha cambiado ni una línea en el proceso: sigue siendo JavaScript puro, sin una sola mención a document. Esa frontera es lo que hará posible el Módulo 8, cuando pruebes el modelo sin navegador.

Y ahora abre las DevTools, crea tres tareas para Iván, marca dos como hechas, filtra por Lucía… y pulsa F5. Todo desaparece. Vuelven las seis tareas de datos/backlog.js, las 48 h, el esfuerzo 124, como si Marta no hubiera trabajado. La aplicación se ve preciosa y no recuerda nada, porque todo lo que ha ocurrido vive en la memoria de una pestaña que acabas de recargar. Peor aún: aunque recordara, Marta, Iván y Lucía trabajarían cada uno con su propia copia, sin manera de ver lo que hacen los demás. Faltan las dos mitades que convierten una página en un producto: guardar los datos en el navegador para que sobrevivan a la recarga, y hablar con un servidor para que el tablero sea el mismo para todo el equipo. Eso es el Módulo 7: APIs del Navegador, que empieza con Almacenamiento Local y de Sesión —donde el toJSON y el static desdeJSON que escribiste en 05-03 van a demostrar por fin para qué estaban ahí—.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados