The Nómada Tasks board already draws itself, filters, sorts and responds to clicks. But Marta still cannot do the most elementary thing: create a task. The backlog is the one in data/backlog.js and that is that. Adding a task means a form, and a form is much more than a few <input>s: it is the point where data you do not control comes in, where everything arrives as text, where you have to decide what is validated in the browser and what in the model, and where accessibility stops being a detail and becomes essential —an error message that can only be seen in red does not exist for someone who cannot see the screen. In this lesson you will build the complete creation form, with two-layer validation, accessible messages, on-the-fly validation with debounce and correct focus management. It is the last piece of Module 6.
Contents
- The creation form: semantic HTML
- The field attributes
- Reading values:
value,FormDataandObject.fromEntries - Everything arrives as text
- The
submitevent andpreventDefault - Native HTML5 validation
- Native validation versus JavaScript validation
- Business rules live in the model
- Showing errors accessibly
- On-the-fly validation with
inputand debounce - Submitting, resetting and returning focus
- Nómada Tasks:
view/form.js - Common Mistakes and Tips
- Exercises
- Conclusion
- The creation form: semantic HTML
We start with the markup, because a well-written form solves half the work before you touch any JavaScript:
<section aria-labelledby="new-task-title">
<h2 id="new-task-title">New task</h2>
<form id="new-task" class="form" novalidate>
<p id="form-errors" class="errors" role="alert" hidden></p>
<fieldset>
<legend>Task details</legend>
<div class="field">
<label for="title">Title <span aria-hidden="true">*</span></label>
<input type="text" id="title" name="title" required
minlength="3" maxlength="80" autocomplete="off"
aria-describedby="title-hint">
<small id="title-hint" class="hint">Between 3 and 80 characters.</small>
</div>
<div class="field">
<label for="assignee">Assignee</label>
<select id="assignee" name="assignee">
<option value="">Unassigned</option>
<option value="Marta">Marta</option>
<option value="Iván">Iván</option>
<option value="Lucía">Lucía</option>
</select>
</div>
<div class="field">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="high">High</option>
<option value="medium" selected>Medium</option>
<option value="low">Low</option>
</select>
</div>
<div class="field">
<label for="estimatedHours">Estimated hours <span aria-hidden="true">*</span></label>
<input type="number" id="estimatedHours" name="estimatedHours"
required min="1" max="40" step="1" inputmode="numeric"
aria-describedby="hours-hint">
<small id="hours-hint" class="hint">Between 1 and 40 hours (rule R3).</small>
</div>
<div class="field">
<label for="dueDate">Due date <span aria-hidden="true">*</span></label>
<input type="date" id="dueDate" name="dueDate" required min="2026-09-20">
</div>
<div class="field">
<label for="tags">Tags</label>
<input type="text" id="tags" name="tags"
pattern="[a-zA-Z0-9, -]*" aria-describedby="tags-hint">
<small id="tags-hint" class="hint">Comma-separated: screen-printing, storeroom</small>
</div>
<div class="field">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="2" maxlength="200"></textarea>
</div>
</fieldset>
<div class="actions">
<button type="submit">Create task</button>
<button type="reset">Clear</button>
</div>
</form>
</section>The decisions that matter:
- Every field has its
<label for="…">pointing at the control'sid. This is not optional: it is what makes a screen reader announce "Title, text field, required" and what lets you activate the field by clicking the label. Aplaceholderis not a substitute for a label: it disappears when you type and many readers do not announce it. nameon every control. It is the key under which the data will appear inFormData. Without aname, the field simply is not submitted. And here we have made them match the property names ofTask(title,estimatedHours,dueDate), which is going to save a lot of work in section 3.<fieldset>with<legend>groups related fields and gives them a common name, which screen readers announce when entering the group. It is essential with radio groups and highly recommended in general.aria-describedbyconnects the field with its hint text, so that it is read along with the field's name.role="alert"on the error container: when you put text in it, screen readers will announce it immediately, without the user having to go looking for it.novalidateon the<form>turns off the browser's error bubbles, but it does not turn off the validation API: we can still querycheckValidity(). That is what will let us show the errors with our own design and with the accessibility we want. We will come back to it in section 6.
- The field attributes
Modern HTML has far more validation capability than people usually use:
| Attribute | Applies to | What it does |
|---|---|---|
required |
Almost all | The field cannot be left empty |
minlength / maxlength |
text, textarea, search |
Length of the text (maxlength also prevents typing more) |
min / max |
number, date, range |
Minimum and maximum value |
step |
number, date, range |
Allowed increment (step="1" = whole numbers only) |
pattern |
text, tel, search… |
Regular expression the value must match |
type |
All | Validates the format: email, url, number, date |
autocomplete |
Almost all | Browser suggestions (off for one-off fields) |
inputmode |
Text and number | Which keyboard a phone shows (numeric, decimal, tel) |
readonly / disabled |
Almost all | Read-only / disabled (disabled is not submitted) |
Two details that are often forgotten:
type="number"does not prevent typing letters in every browser, but it does makeinput.valuereturn an empty string if the content is not a valid number. It is behavior that surprises people: the field looks like it has text andvalueis empty.disabledexcludes the field from submission;readonlyincludes it. If you need to show a fixed value and have it arrive with the submission, usereadonly.
- Reading values:
value, FormData and Object.fromEntries
value, FormData and Object.fromEntriesThere are three levels, from most manual to most automatic.
Level 1: field by field.
const form = document.querySelector('#new-task');
const title = form.querySelector('#title').value;
const hours = form.querySelector('#estimatedHours').value;It works, but every new field is two more lines and an id that can go stale.
Level 2: form.elements. Every form has a collection of its controls, accessible by name:
console.log(form.elements.title.value); // by name
console.log(form.elements['estimatedHours'].value);
console.log(form.elements.length); // number of controlsIt is convenient for reaching a specific control without looking it up with querySelector.
Level 3: FormData + Object.fromEntries. This is the idiomatic form:
const data = new FormData(form);
// FormData is iterable: [name, value] pairs
for (const [key, value] of data) {
console.log(key, '=', JSON.stringify(value));
}
// title = "Buy white screen-printing ink"
// assignee = "Lucía"
// priority = "high"
// estimatedHours = "4"
// dueDate = "2026-10-10"
// tags = "screen-printing, storeroom"
// notes = ""
// And with Object.fromEntries (04-05), a plain object in a single line:
const raw = Object.fromEntries(data);
console.log(raw);
// { title: 'Buy white screen-printing ink', assignee: 'Lucía', priority: 'high',
// estimatedHours: '4', dueDate: '2026-10-10', tags: 'screen-printing, storeroom', notes: '' }Here you collect the dividend of having written name="estimatedHours" instead of name="hours": the object's keys already match the properties the Task constructor expects.
Two limitations of Object.fromEntries worth knowing:
- With repeated names, it keeps the last one. A group of checkboxes with the same
namerequiresdata.getAll('tags'), which returns an array with every value. - Unchecked checkboxes do not appear in
FormData. They are notfalse: they are simply not there. You have to account for their absence with?? falseor query thecheckedproperty.
- Everything arrives as text
Look at the output above again: estimatedHours is '4', the string, not the number. It is the same boundary you already saw with dataset in 06-02 and the whole reason for lesson 01-07.
const raw = Object.fromEntries(new FormData(form));
console.log(typeof raw.estimatedHours); // 'string'
console.log(raw.estimatedHours + 1); // '41' ← concatenation
console.log(raw.estimatedHours > 40); // false ← '4' > 40 compares badlyThat last case is especially treacherous: '4' > 40 is converted to 4 > 40 and gives false by coincidence; but '9' > 40 also gives false, and '10' > '9' gives false because it compares strings. Any numeric validation over unconverted text is a time bomb.
The solution is an explicit boundary conversion function, with Number (never parseInt, which accepts '12abc' and returns 12):
/** Converts the raw form data to the model's types. */
function normalize(raw) {
return {
title: raw.title.trim(),
assignee: raw.assignee === '' ? null : raw.assignee, // R8
priority: raw.priority,
estimatedHours: Number(raw.estimatedHours), // '4' → 4
dueDate: raw.dueDate, // already 'yyyy-mm-dd'
tags: raw.tags
.split(',')
.map((t) => t.trim().toLowerCase())
.filter((t) => t !== ''), // R9
notes: raw.notes.trim()
};
}
console.log(normalize(raw));
// { title: 'Buy white screen-printing ink', assignee: 'Lucía', priority: 'high',
// estimatedHours: 4, dueDate: '2026-10-10',
// tags: ['screen-printing', 'storeroom'], notes: '' }A warning about Number with the empty string: Number('') is 0, not NaN. If a required numeric field is submitted empty and the model expects a number, 0 would pass the conversion and fail later with a confusing message. That is why the HTML's required and the model's validation complement each other.
And a note about type="date": its value is always a 'yyyy-mm-dd' string, exactly the ISO format Nómada Tasks uses. There are also input.valueAsNumber (milliseconds) and valueAsDate (a Date object), but for us the string is ideal, because it is what the model stores.
- The
submit event and preventDefault
submit event and preventDefaultA form's submission is listened for on the <form>, not on the button:
form.addEventListener('submit', (event) => {
event.preventDefault(); // ← without this, the page reloads and you lose everything
// … process
});Without preventDefault(), the browser does what it did in 1995: it serializes the form, navigates to the URL in the action attribute (or reloads the current one) and your whole application starts over. It is mistake number one with forms and it produces the baffling symptom of "the page flickers and nothing happens".
Useful details about submit:
- It fires with Enter inside a text field, not only when the button is pressed. It is a convenience users expect: do not break it by listening for
clickon the button instead ofsubmiton the form. - A
<button>inside a form istype="submit"by default. A button that does something else needs an explicittype="button", or it will submit the form by accident. event.submittertells you which button caused the submission, useful when there are several ("Create" and "Create and duplicate").type="reset"fires aresetevent and empties the fields; it is cancelable too.
- Native HTML5 validation
The browser comes with a complete validation engine, and wasting it is a frequent mistake. Every control has:
const field = form.elements.estimatedHours;
console.log(field.validity);
// ValidityState { valueMissing: false, rangeOverflow: true, badInput: false, valid: false, … }
console.log(field.checkValidity()); // false · is it valid? (fires an 'invalid' event)
console.log(field.validationMessage); // 'Value must be less than or equal to 40.'
console.log(field.willValidate); // true · does it take part in validation?The validity object has one flag per kind of failure, and this allows precise messages:
| Flag | Turns on when |
|---|---|
valueMissing |
It is required and it is empty |
typeMismatch |
It does not match the type (email, url) |
patternMismatch |
It does not match the pattern |
tooShort / tooLong |
It breaks minlength / maxlength |
rangeUnderflow / rangeOverflow |
It breaks min / max |
stepMismatch |
It does not fit the step |
badInput |
The browser cannot interpret what was typed (letters in type="number") |
customError |
You have called setCustomValidity |
valid |
None of the above |
At form level:
form.checkValidity(); // are ALL the fields valid? (without showing anything)
form.reportValidity(); // the same, but it also SHOWS the browser's bubblesAnd setCustomValidity lets you add an error of your own to the native engine:
const date = form.elements.dueDate;
date.setCustomValidity('The due date cannot be earlier than today.');
console.log(date.validity.customError); // true
console.log(date.checkValidity()); // false
date.setCustomValidity(''); // ← empty string = valid field againGolden rule with setCustomValidity: it has to be cleared. If you set a message and do not delete it when the user corrects things, the field stays invalid forever and the form will never submit, with no clue as to why.
In CSS you can react to validity with pseudo-classes:
.field input:user-invalid,
.field select:user-invalid { border-color: var(--color-high); }
.field input:user-valid { border-color: var(--color-low); }:user-invalid is preferable to :invalid because it only turns on after the user has interacted with the field. With plain :invalid, every required field shows up in red as soon as the page loads, before anybody has typed anything: a hostile experience.
- Native validation versus JavaScript validation
| Aspect | Native validation (HTML5) | JavaScript validation |
|---|---|---|
| Effort | One attribute | Code to write and maintain |
| Works without JS | Yes | No |
| Control of the message | Limited (browser's language) | Total |
| Control of design and timing | Scarce | Total |
| Rules across several fields | Cannot | Yes |
| Business rules (uniqueness, quotas) | Cannot | Yes |
| Accessibility | Good by default | Has to be built |
The correct answer is not to choose one: it is to use both, in layers.
- HTML layer:
required,min,max,pattern,type. Cheap, works without JavaScript, guides the user while typing and makes phones show the right keyboard. - View's JavaScript layer: your own messages, when to show them, accessibility, cross-field rules.
- Model layer: the real business rules, which have existed since Module 5 and which are not duplicated.
And a fourth layer that is not optional in a real application: the server. All the browser's validation can be bypassed with the DevTools in ten seconds. It exists to help the user, never to guarantee data integrity. Nómada Tasks does not have a server yet; it will in Module 7.
- Business rules live in the model
This is the most important architectural decision of the lesson. Look at what Task already knows how to do since 05-02 and 05-03:
- R1:
Board.addrejects duplicated ids. - R2: the constructor throws
ValidationErrorif the title is empty. - R3: the
estimatedHourssetter demands a number between 1 and 40. - R5: the initial status is
'pending'. - R9: tags are normalized and deduplicated.
It would be a mistake to rewrite all of that in the view. If you did, you would have two copies of each rule, which would go out of sync with the first modification, and Module 8 would have to test them twice.
The right thing is to try to build the task and catch the error. The view does not validate business rules: it limits itself to translating the model's error into a message on screen.
import { Task } from '../model/task.js';
import { ValidationError } from '../model/errors.js';
function tryCreate(data, board, nextId) {
try {
const task = new Task({ ...data, id: nextId() }); // R2, R3, R5, R9
board.add(task); // R1
return { ok: true, task };
} catch (error) {
if (error instanceof ValidationError) {
return { ok: false, field: error.field, message: error.message };
}
throw error; // any other error is a genuine failure: let it rise (02-05)
}
}That error.field is gold: the ValidationError you designed in 05-02 already carries the name of the field that failed, so the view knows exactly where to put the message and which field to move focus to. Designing the errors that way, months before there was an interface, is what makes them fit now without any effort.
The ids are generated by the closure from 03-04, which is still the right way of not exposing a global counter:
// js/util/ids.js
export function createIdGenerator(start = 1) {
let next = start;
return () => next++;
}// js/app.js
import { createIdGenerator } from './util/ids.js';
const nextId = createIdGenerator(
Math.max(...board.tasks.map((t) => t.id)) + 1 // 7, with the canonical backlog
);What is left are the validations that do belong to the view, because they are not domain rules but rules of this specific form: that the due date is not earlier than today, that the tags field does not bring more than five, or any check that depends on the relationship between two fields.
- Showing errors accessibly
An error message has to satisfy four things. If it fails one, there are people who cannot use your form:
- Be associated with the field, with
aria-describedby, so that it is read along with it. - Mark the field as invalid, with
aria-invalid="true", so that it is announced as such. - Announce itself when it appears, with
role="alert"on the general summary. - Move focus to the first field with an error, so that keyboard users do not have to hunt for it.
And a fifth requirement that is not technical: color cannot be the only signal. A red border with no text says nothing to somebody who cannot distinguish red.
// js/view/form.js (fragment)
import { $ } from './dom.js';
/** Marks a field as erroneous and shows its message. */
function markError(field, message) {
field.setAttribute('aria-invalid', 'true');
const errorId = `error-${field.name}`;
let error = document.getElementById(errorId);
if (error === null) {
error = document.createElement('small');
error.id = errorId;
error.className = 'field__error';
field.closest('.field').append(error);
}
error.textContent = message;
// We add the error's id to aria-describedby WITHOUT deleting the hint's one
const describedBy = (field.getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
if (!describedBy.includes(errorId)) {
field.setAttribute('aria-describedby', [...describedBy, errorId].join(' '));
}
}
/** Clears a field's error. */
function clearError(field) {
field.removeAttribute('aria-invalid');
field.setCustomValidity(''); // ← essential
document.getElementById(`error-${field.name}`)?.remove();
const describedBy = (field.getAttribute('aria-describedby') ?? '')
.split(' ').filter((id) => id !== `error-${field.name}` && id !== '');
if (describedBy.length > 0) field.setAttribute('aria-describedby', describedBy.join(' '));
else field.removeAttribute('aria-describedby');
}.field__error { color: var(--color-high); font-weight: 600; display: block; }
.field__error::before { content: "⚠ "; } /* non-chromatic signal */
[aria-invalid="true"] { border: 2px solid var(--color-high); }
.errors { color: var(--color-high); font-weight: 600; }Note the care taken with aria-describedby: it can contain several space-separated ids, and the field already had the one for its hint text. Clobbering it with setAttribute('aria-describedby', errorId) would make the hint disappear. It is a small detail with real consequences.
And the focus move, which happens only once, to the first error:
function showErrors(form, errors) {
const summary = $('#form-errors');
if (errors.length === 0) {
summary.hidden = true;
summary.textContent = '';
return;
}
summary.textContent = errors.length === 1
? errors[0].message
: `There are ${errors.length} fields with errors. Review them before continuing.`;
summary.hidden = false; // role="alert" announces it when shown
for (const { field, message } of errors) markError(field, message);
errors[0].field.focus(); // ← focus to the FIRST error
}
- On-the-fly validation with
input and debounce
input and debounceValidating only on submit is frustrating: the user fills in seven fields and finds out at the end that the second one was wrong. Validating on every key is just as annoying: "the title is too short" pops up as soon as the first letter is typed.
The balance that works:
| Moment | What to validate |
|---|---|
blur / focusout |
The field just left (first time) |
input |
Only the fields already marked as erroneous, to remove the error as soon as it is fixed |
submit |
Everything |
And for expensive or noisy validations —a search for duplicated titles, for instance— a debounce is applied: wait until the user has stopped typing for a while before acting. It is implemented with the closure from 03-04:
// js/util/time.js
/**
* Returns a version of `fn` that only runs once `wait`
* milliseconds have passed since the last call.
*/
export function debounce(fn, wait = 300) {
let timer = null; // ← lives in the closure, one per created function
return function (...args) {
clearTimeout(timer); // cancels the pending execution
timer = setTimeout(() => fn.apply(this, args), wait);
};
}const validateTitle = debounce((field) => {
if (field.value.trim().length < 3) markError(field, 'Minimum 3 characters.');
else clearError(field);
}, 300);
form.elements.title.addEventListener('input', (e) => validateTitle(e.target));A trace of what happens if Marta types "Buy white ink" in one second:
B → schedules a validation in 300 ms u → cancels the previous one, schedules another y → cancels, schedules … k → cancels, schedules (300 ms pause) → it runs ONCE, with the final value
Thirteen keystrokes, a single validation. With local data the saving is symbolic; with a check against a server (Module 7) it is the difference between thirteen requests and one.
Do not confuse debounce with throttle: the first waits for the events to stop; the second runs at most once every X milliseconds, and is the right choice for scroll or resize. Both techniques are studied in detail in Optimizing JavaScript Performance.
An accessibility warning about debounce: with role="alert", every text change is announced. If you validate a field on the fly while the user types, the screen reader will interrupt constantly. That is why the on-the-fly messages go in the field's <small> (with aria-describedby, which is read when the user reaches the field) and only the submission summary uses role="alert".
- Submitting, resetting and returning focus
When the submission goes well, three gestures separate a careful form from a careless one:
form.reset(); // empties the fields (respects the 'selected' ones)
form.elements.title.focus(); // returns focus to the first field
announce(`Task "${task.title}" created.`); // audible and visible confirmationreset()returns every control to its initial value from the HTML, not to the empty string: that is why<option value="medium" selected>becomes selected again. It also clears the:user-invalidstate.- Returning focus to the first field lets you create several tasks in a row without touching the mouse. If you do not, focus stays on the "Create" button and the user has to go back with Shift+Tab seven times.
- The confirmation must be perceivable by everyone. Text appearing in a container with
role="status"is announced without interrupting; a silent animation is not.
Remember also to clear the previous errors on every submission: if you do not, fields that have already been fixed stay marked.
- Nómada Tasks:
view/form.js
view/form.jsThe complete module, which brings the three validation layers together:
// js/view/form.js
import { $, $$ } from './dom.js';
import { debounce } from '../util/time.js';
import { Task } from '../model/task.js';
import { ValidationError } from '../model/errors.js';
import { EVENTS, emit } from './events.js';
import { TODAY } from '../util/dates.js';
/** Converts the raw form data to the model's types. */
function normalize(raw) {
return {
title: (raw.title ?? '').trim(),
assignee: raw.assignee === '' ? null : raw.assignee,
priority: raw.priority,
estimatedHours: raw.estimatedHours === '' ? NaN : Number(raw.estimatedHours),
dueDate: raw.dueDate,
tags: (raw.tags ?? '')
.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
};
}
/** Validations specific to THIS form (not domain rules). */
function validateForm(form, data, today) {
const errors = [];
for (const field of form.elements) {
if (field.willValidate && !field.checkValidity()) {
errors.push({ field, message: field.validationMessage }); // native layer
}
}
const date = form.elements.dueDate;
if (errors.every((e) => e.field !== date) && data.dueDate < today) {
errors.push({ field: date, message: 'The due date cannot be earlier than today.' });
}
if (data.tags.length > 5) {
errors.push({ field: form.elements.tags, message: 'Maximum 5 tags.' });
}
return errors;
}
export function connectForm({ form, board, nextId, onCreate,
today = TODAY, signal }) {
const summary = $('#form-errors');
function clearAll() {
summary.hidden = true;
summary.textContent = '';
for (const field of form.elements) {
if (field.name) clearError(field);
}
}
// ── 1 · Submission ───────────────────────────────────────────────────────
form.addEventListener('submit', (event) => {
event.preventDefault(); // essential
clearAll();
const data = normalize(Object.fromEntries(new FormData(form)));
// Layer A · form validation (native + the view's rules)
const errors = validateForm(form, data, today);
if (errors.length > 0) { showErrors(form, errors); return; }
// Layer B · business rules: applied by the MODEL, not by the view
let task;
try {
task = new Task({ ...data, id: nextId(), status: 'pending' }); // R2, R3, R5, R9
board.add(task); // R1
} catch (error) {
if (!(error instanceof ValidationError)) throw error;
const field = form.elements[error.field] ?? form.elements.title;
showErrors(form, [{ field, message: error.message }]);
return;
}
// Success
emit(form, EVENTS.TASK_CREATED, { id: task.id, title: task.title });
onCreate?.(task); // the caller decides what to do (render)
form.reset();
form.elements.title.focus();
summary.hidden = false;
summary.textContent = `Task "${task.title}" created with identifier ${task.id}.`;
}, { signal });
// ── 2 · Validation when leaving a field ──────────────────────────────────
form.addEventListener('focusout', (event) => { // focusin/focusout DO bubble
const field = event.target;
if (!field.name || !field.willValidate) return;
if (field.value === '' && !field.required) return; // do not nag about optional fields
if (field.checkValidity()) clearError(field);
else markError(field, field.validationMessage);
}, { signal });
// ── 3 · On-the-fly correction, with debounce ─────────────────────────────
const revalidate = debounce((field) => {
if (field.checkValidity()) clearError(field);
}, 300);
form.addEventListener('input', (event) => {
const field = event.target;
// We only revalidate what was ALREADY marked as erroneous
if (field.getAttribute('aria-invalid') === 'true') revalidate(field);
}, { signal });
// ── 4 · Clean reset ──────────────────────────────────────────────────────
form.addEventListener('reset', () => {
clearAll();
setTimeout(() => form.elements.title.focus(), 0); // after the browser's reset
}, { signal });
}And the final wiring in app.js, with the module's complete cycle:
// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { createIdGenerator } from './util/ids.js';
import { TODAY } from './util/dates.js';
import { BoardView } from './view/board-view.js';
import { connectForm } from './view/form.js';
import { $ } from './view/dom.js';
const board = new Board('Taller Nómada', createBacklog());
const nextId = createIdGenerator(Math.max(...board.tasks.map((t) => t.id)) + 1);
const view = new BoardView({
container: $('.board'), summary: $('#summary'), board, today: TODAY
});
view.render();
connectForm({
form: $('#new-task'),
board,
nextId,
today: TODAY,
onCreate: () => view.render() // ← new state, render: the cycle from 06-06
});Try it. Create "Buy white screen-printing ink", Lucía, high priority, 4 h, 10 October, tags "screen-printing, storeroom". The card appears in the "Not started" column, the counter goes from 3 tasks · 25 h to 4 tasks · 29 h, and the summary from 6 tasks · 5 open · 45 of 48 h to 7 tasks · 6 open · 49 of 52 h. Then try submitting with an empty title: the summary announces the error, the field is marked with aria-invalid, the message appears under the label and focus jumps to the field. And try entering 60 hours: the HTML's max="40" catches it before it reaches the model, and if you bypassed that with the DevTools, the estimatedHours setter (R3) would reject it just the same. Two layers, the same rule, a single definition.
Common Mistakes and Tips
- Forgetting
preventDefault()on thesubmit. The page reloads and all the state is lost. If your form "flickers and does nothing", this is the reason 90 % of the time. - Listening for
clickon the button instead ofsubmiton the form. It breaks submitting with Enter, which many users take for granted. - Using
<button>without atypefor actions that do not submit. Inside a form, the default value issubmit. Any auxiliary button needstype="button". - Using a
placeholderinstead of a<label>. It disappears when you type, it is not always announced and it cannot be clicked. The label is mandatory; theplaceholderis an extra. - Not converting the values. Everything arrives as text:
'4' > 40givesfalseby coincidence and'10' > '9'givesfalsethrough string comparison. Convert withNumber()in a normalization function. - Forgetting
setCustomValidity('')when things are corrected. The field stays invalid forever and the form never submits, with no visible clue. - Clobbering
aria-describedby. It can contain several ids; overwriting it removes the reference to the hint text. Add and remove ids while preserving the others. - Duplicating the business rules in the view. If
Taskalready validates the hours range, the view must not repeat it: it must catch theValidationErrorand use itsfieldand itsmessage. One rule, one place. - Marking everything red on page load. That is what plain
:invaliddoes. Use:user-invalid, which waits for the user to interact. - Trusting client-side validation. It is bypassed with the DevTools in ten seconds. It is help for the user, not a guarantee; the guarantee comes from the server (Module 7).
- Tip: make the
names match the model's properties.Object.fromEntries(new FormData(form))then produces an object almost ready for the constructor, and you save yourself an entire mapping. - Tip: test your form with the keyboard only. Tab, Shift+Tab, Enter, Escape. If you can fill it in, submit it, fix an error and submit it again without touching the mouse, it is well made.
Exercises
Exercise 1 · Reviewer different from the assignee
Add a <select name="reviewer"> to the form with the same people and a cross-field validation: the reviewer cannot be the same person as the assignee. It must show as an accessible error on the reviewer field, be revalidated when either of the two changes, and not prevent both from being left unassigned.
Exercise 2 · Accessible character counter
Add under the notes field a counter "120 / 200 characters" that updates as you type and warns when fewer than 20 are left. It must use input, must not interrupt screen readers on every key, and must apply a debounce to the announcement. Explain which combination of aria-live and debounce you chose and why.
Exercise 3 · Duplicates by title
Prevent the creation of two open tasks with the same title (comparing without distinguishing case or superfluous accents). Decide with reasons which layer this rule lives in, implement it there, and validate on the fly with a 400 ms debounce, showing the warning before the user presses "Create task".
Solutions
Exercise 1
<div class="field">
<label for="reviewer">Reviewer</label>
<select id="reviewer" name="reviewer">
<option value="">No reviewer</option>
<option value="Marta">Marta</option>
<option value="Iván">Iván</option>
<option value="Lucía">Lucía</option>
</select>
</div>function validateReviewer(form) {
const reviewer = form.elements.reviewer;
const assignee = form.elements.assignee;
const clashes = reviewer.value !== '' && reviewer.value === assignee.value;
reviewer.setCustomValidity(clashes ? 'The reviewer cannot be the assignee.' : '');
if (clashes) markError(reviewer, reviewer.validationMessage);
else clearError(reviewer);
return !clashes;
}
// It is revalidated when EITHER of the two changes
for (const name of ['reviewer', 'assignee']) {
form.elements[name].addEventListener('change', () => validateReviewer(form));
}Two important decisions. The first: setCustomValidity is used, which integrates the rule into the native engine and makes the submission's checkValidity() loop detect it with no extra code. The second: we listen on both fields, because the error can appear or disappear when either of them changes; validating only the reviewer would leave a stale error if the user fixes things by changing the assignee. And the reviewer.value !== '' lets both be left empty, exactly as the exercise asked: "unassigned" and "no reviewer" do not clash with each other.
Exercise 2
<small id="notes-counter" class="hint" aria-live="polite">0 / 200 characters</small>
<textarea id="notes" name="notes" rows="2" maxlength="200"
aria-describedby="notes-counter"></textarea>const notes = form.elements.notes;
const counter = $('#notes-counter');
// Immediate VISUAL update, without an active aria-live
const announce = debounce((text) => { counter.textContent = text; }, 500);
notes.addEventListener('input', () => {
const used = notes.value.length;
const remaining = 200 - used;
const text = `${used} / 200 characters`;
counter.classList.toggle('hint--warning', remaining < 20);
announce(remaining < 20 ? `${text}. ${remaining} left.` : text);
});The chosen combination is aria-live="polite" plus a 500 ms debounce, and the reasoning is this: polite makes the reader wait until it has finished whatever it is saying before announcing the change, instead of interrupting as assertive or role="alert" would. But even being polite, updating the text on every key would queue dozens of announcements. The debounce guarantees that it is only announced when the user pauses, which is exactly the moment the information is useful to them. The warning class, by contrast, is applied immediately: it is visual information, it costs nothing and it interrupts nobody.
Exercise 3
The rule "there cannot be two open tasks with the same title" is a business rule, not a presentation one: it stays true even if the task is created from a script, from an import or from the server. It therefore lives in the model, alongside R1.
// model/board.js
#normalizeTitle(title) {
return title.trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
/** Is there already an OPEN task with this title? (R11) */
hasDuplicateTitle(title, exceptId = null) {
const wanted = this.#normalizeTitle(title);
return this.#tasks.some(
(t) => t.isOpen && t.id !== exceptId && this.#normalizeTitle(t.title) === wanted
);
}
add(task) {
// … R1 …
if (this.hasDuplicateTitle(task.title)) {
throw new ValidationError(
`An open task titled "${task.title}" already exists.`, 'title', task.title);
}
this.#tasks.push(task);
return this;
}// view/form.js — on-the-fly warning, without duplicating the rule
const warnDuplicate = debounce((field) => {
const duplicate = board.hasDuplicateTitle(field.value);
field.setCustomValidity(duplicate ? 'An open task with that title already exists.' : '');
if (duplicate) markError(field, field.validationMessage);
else clearError(field);
}, 400);
form.elements.title.addEventListener('input', (e) => warnDuplicate(e.target));The key is that the view asks the model (board.hasDuplicateTitle(...)) instead of reimplementing the comparison. If tomorrow it is decided that done tasks also count, or that punctuation should be ignored, a single function is changed and both the on-the-fly warning and the rejection at submission behave the same way. The normalize('NFD') followed by stripping the diacritics is the standard way of comparing "Café signage" and "Cafe signage" as equal.
Conclusion
With this, Nómada Tasks is complete as a browser application. You know how to write a semantic form: every control with its <label for>, its name —made to match the model's properties, which saves an entire mapping—, its <fieldset> and <legend> for grouping, its aria-describedby for the hints and its validation attributes (required, minlength, min, max, step, pattern, type="date", type="number"). You know how to read the values at three levels, from element.value to the idiomatic Object.fromEntries(new FormData(form)), with the two limitations worth remembering: repeated names require getAll, and unchecked checkboxes simply do not appear. And you have it engraved that everything arrives as text, with '4' > 40 and '10' > '9' as reminders of why a normalization function with Number() is not optional.
You have mastered submit with its mandatory preventDefault() and its details —it fires with Enter, buttons are submit by default, event.submitter tells you which was pressed— and the two ways of validating, which do not compete but stack. The native one contributes checkValidity, reportValidity, the validity object with one flag per kind of failure, setCustomValidity (and the obligation to clear it), novalidate to keep the API without the browser's bubbles, and :user-invalid instead of :invalid so as not to dye the page red before anybody has typed. The JavaScript one contributes control of the message, of the timing and of accessibility, plus the cross-field rules. And above both stands the architectural decision that organizes the whole project: business rules live in the model. The view does not reimplement R1, R2, R3 or R9; it tries to build the Task, catches the ValidationError and uses its field and its message to know where to put the message and which field to move focus to. Designing that error with a field property, two modules before a screen existed, is what makes it fit today without a line of glue.
You know how to show errors that exist for everybody: aria-invalid="true" on the field, the message linked with aria-describedby —preserving whatever ids were already there—, a summary with role="alert" that announces itself, focus moved to the first field with an error, and a signal that does not depend on color alone. You know how to validate on the fly at the right moment —when leaving a field the first time, and on every key only to remove errors already shown— and to damp the noise with a debounce built from a closure from 03-04, telling it apart from throttle and bearing in mind that an undamped aria-live turns a screen reader into a machine gun. And you close the cycle with the three final gestures: reset(), focus to the first field and a perceivable confirmation.
This brings Module 6 to an end. You have covered the whole DOM: what the tree is and how the browser builds it; how you select with CSS selectors and how you manipulate text, attributes, dataset, classes and styles; how handlers are registered and what the Event object brings; how an event travels through the three phases and how delegation with closest() and data-id lets you serve a whole list with a single handler; how nodes are created, inserted and removed without opening security holes or memory leaks; how the cycle state → render → event → new state → render is organized with key-based reconciliation; and how user data is collected and validated. The model from Modules 1 to 5 has not changed a single line in the process: it is still pure JavaScript, without one mention of document. That boundary is what will make Module 8 possible, when you test the model without a browser.
And now open the DevTools, create three tasks for Iván, mark two as done, filter by Lucía… and press F5. Everything disappears. Back come the six tasks of data/backlog.js, the 48 h, effort 124, as if Marta had never done any work. The application looks beautiful and remembers nothing, because everything that has happened lives in the memory of a tab you have just reloaded. Worse still: even if it did remember, Marta, Iván and Lucía would each work with their own copy, with no way of seeing what the others are doing. Missing are the two halves that turn a page into a product: storing the data in the browser so that it survives a reload, and talking to a server so that the board is the same one for the whole team. That is Module 7: Browser APIs, which starts with Local and Session Storage —where the toJSON and the static fromJSON you wrote in 05-03 will finally show what they were there for.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
