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
- El formulario de alta: HTML semántico
- Los atributos de los campos
- Leer valores:
value,FormDatayObject.fromEntries - Todo llega como texto
- El evento
submitypreventDefault - Validación nativa de HTML5
- Validación nativa frente a validación en JavaScript
- Las reglas de negocio viven en el modelo
- Mostrar errores de forma accesible
- Validación al vuelo con
inputy debounce - Enviar, resetear y devolver el foco
- Nómada Tareas:
vista/formulario.js - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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 aliddel 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. Unplaceholderno sustituye a una etiqueta: desaparece al escribir y muchos lectores no lo anuncian. nameen cada control. Es la clave con la que el dato aparecerá enFormData. Sinname, el campo simplemente no se envía. Y aquí los hemos hecho coincidir con los nombres de las propiedades deTarea(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-describedbyconecta 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.novalidateen el<form>desactiva los globos de error del navegador, pero no desactiva la API de validación: seguimos pudiendo consultarcheckValidity(). 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.
- 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 queinput.valuedevuelva cadena vacía si el contenido no es un número válido. Es un comportamiento que sorprende: el campo parece tener texto yvalueestá vacío.disabledexcluye el campo del envío;readonlylo incluye. Si necesitas mostrar un valor fijo y que llegue en el envío,readonly.
- Leer valores:
value, FormData y Object.fromEntries
value, FormData y Object.fromEntriesHay 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 controlesEs 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
namerequieredatos.getAll('etiquetas'), que devuelve un array con todos los valores. - Los checkbox sin marcar no aparecen en
FormData. No valenfalse: sencillamente no están. Hay que dar por hecho su ausencia con?? falseo consultar la propiedadchecked.
- 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 malEse ú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.
- El evento
submit y preventDefault
submit y preventDefaultEl 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
clicken el botón en lugar desubmiten el formulario. <button>dentro de un formulario estype="submit"por defecto. Un botón que hace otra cosa necesitatype="button"explícito, o enviará el formulario sin querer.evento.submitterindica qué botón provocó el envío, útil si hay varios («Crear» y «Crear y duplicar»).type="reset"dispara un eventoresety vacía los campos; también es cancelable.
- 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 navegadorY 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 vezRegla 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.
- 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 | Sí | No |
| Control del mensaje | Limitado (idioma del navegador) | Total |
| Control del diseño y el momento | Escaso | Total |
| Reglas entre varios campos | No puede | Sí |
| Reglas de negocio (unicidad, cupos) | No puede | Sí |
| Accesibilidad | Buena por defecto | Hay que construirla |
La respuesta correcta no es elegir una: es usar las dos en capas.
- 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. - Capa JavaScript de la vista: mensajes propios, momento de mostrarlos, accesibilidad, reglas entre campos.
- 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.
- 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.agregarrechaza ids duplicados. - R2: el constructor lanza
ErrorDeValidacionsi el título está vacío. - R3: el setter de
horasEstimadasexige 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 sí 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.
- 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:
- Estar asociado al campo, con
aria-describedby, para que se lea junto a él. - Marcar el campo como inválido, con
aria-invalid="true", para que se anuncie como tal. - Anunciarse al aparecer, con
role="alert"en el resumen general. - 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
}
- Validación al vuelo con
input y debounce
input y debounceValidar 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".
- 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 visiblereset()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.
- Nómada Tareas:
vista/formulario.js
vista/formulario.jsEl 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 elsubmit. 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
clicken el botón en vez desubmiten el formulario. Rompe el envío con Enter, que muchos usuarios dan por hecho. - Poner
<button>sintypepara acciones que no envían. Dentro de un formulario, el valor por defecto essubmit. Cualquier botón auxiliar necesitatype="button". - Usar
placeholderen lugar de<label>. Desaparece al escribir, no siempre se anuncia y no se puede pulsar. La etiqueta es obligatoria; elplaceholderes un extra. - No convertir los valores. Todo llega como texto:
'4' > 40dafalsepor casualidad y'10' > '9'dafalsepor comparación de cadenas. Convierte conNumber()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
Tareaya valida el rango de horas, la vista no debe repetirlo: debe capturar elErrorDeValidaciony usar sucampoy sumessage. Una regla, un sitio. - Marcar todo en rojo al cargar la página. Es lo que hace
:invalida 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
namecon 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
