The signup form
The last section of the landing page, built with the lesson 3-1 patterns.
<section id="signup"> <h2>Join the group</h2> <form id="signup-form" novalidate> <label for="name">Name</label> <input id="name" name="name" type="text"> <label for="email">Email</label> <input id="email" name="email" type="email"> <label for="password">Password</label> <input id="password" name="password" type="password"> <button type="submit">Sign up</button> </form> <p id="form-errors"></p> </section>
| Piece | Job |
|---|---|
each label with for | ties the text to its input |
novalidate | switches off the browser's own checks |
the empty #form-errors | a place to write messages into |
novalidate is deliberate, because the checks written here carry custom messages that the built-in ones cannot.
The wiring comes straight from lesson 8-3.
const form = document.querySelector("#signup-form"); form.addEventListener("submit", (event) => { event.preventDefault(); const errors = validate({ name: document.querySelector("#name").value, email: document.querySelector("#email").value, password: document.querySelector("#password").value, }); document.querySelector("#form-errors").textContent = errors.join(" "); });
Running the validator on its own
validate is pure logic with no browser involved, so it runs anywhere. It reuses looksLikeEmail from lesson 3-1 and returns an array of error messages, empty when everything passes.
function looksLikeEmail(value) { const at = value.indexOf("@"); if (at < 1) return false; if (value.indexOf("@", at + 1) !== -1) return false; return value.indexOf(".", at + 1) !== -1; } function validate(fields) { const errors = []; if (fields.name.trim().length === 0) { errors.push("Name is required."); } if (!looksLikeEmail(fields.email)) { errors.push("Enter a valid email."); } if (fields.password.length < 8) { errors.push("Password needs 8+ characters."); } return errors; } console.log(JSON.stringify(validate({ name: "Ada", email: "ada@example.com", password: "hyperion8" }))); console.log(JSON.stringify(validate({ name: "", email: "ada@example", password: "short" })));
Output
[] ["Name is required.","Enter a valid email.","Password needs 8+ characters."]
| Field | Rule | Message |
|---|---|---|
| name | not blank after trim | Name is required. |
passes looksLikeEmail | Enter a valid email. | |
| password | 8 characters or more | Password needs 8+ characters. |
JSON.stringify prints the array in a stable one-line format, which makes the two outputs directly comparable.
Collecting every failure rather than returning on the first one is the important design choice. A user who fixes one field at a time through three submissions is a user who leaves, so all three messages arrive at once.
Adding a rule to the validator
One more rule, requiring at least one digit in the password, with the existing rules untouched.
function looksLikeEmail(value) { const at = value.indexOf("@"); if (at < 1) return false; if (value.indexOf("@", at + 1) !== -1) return false; return value.indexOf(".", at + 1) !== -1; } function validate(fields) { const errors = []; if (fields.name.trim().length === 0) { errors.push("Name is required."); } if (!looksLikeEmail(fields.email)) { errors.push("Enter a valid email."); } if (fields.password.length < 8) { errors.push("Password needs 8+ characters."); } let hasDigit = false; for (const ch of fields.password) { if (ch >= "0" && ch <= "9") hasDigit = true; } if (!hasDigit) { errors.push("Password needs a number."); } return errors; } console.log(JSON.stringify(validate({ name: "Ada", email: "ada@example.com", password: "hyperion8" }))); console.log(JSON.stringify(validate({ name: "Ada", email: "ada@example.com", password: "hyperionx" })));
Output
[]
["Password needs a number."]| Step | Code |
|---|---|
| walk the characters | for (const ch of fields.password) |
| test one character | ch >= "0" && ch <= "9" |
| decide after the loop | if (!hasDigit) |
A hasDigit boolean tracks the answer across the loop, and the message is pushed only once the whole password has been seen.
The character comparison works because digits are adjacent in character order, so a range check is enough. Pushing inside the loop instead would add the same message once per non-digit character, which is the bug this structure avoids.
The form, wired end to end
The whole unit joined together, with HTML, CSS, and a form that validates before it submits.
CSS
body { font-family: sans-serif; max-width: 340px; }
label, input { display: block; width: 100%; }
input { margin-bottom: 10px; padding: 6px; }
button { padding: 8px 16px; }
#form-errors { color: crimson; }JavaScript
function looksLikeEmail(value) { const at = value.indexOf("@"); if (at < 1) return false; if (value.indexOf("@", at + 1) !== -1) return false; return value.indexOf(".", at + 1) !== -1; } function validate(fields) { const errors = []; if (fields.name.trim().length === 0) errors.push("Name is required."); if (!looksLikeEmail(fields.email)) errors.push("Enter a valid email."); if (fields.password.length < 8) errors.push("Password needs 8+ characters."); return errors; } const form = document.querySelector("#signup-form"); const output = document.querySelector("#form-errors"); form.addEventListener("submit", (event) => { event.preventDefault(); const errors = validate({ name: document.querySelector("#name").value, email: document.querySelector("#email").value, password: document.querySelector("#password").value, }); output.textContent = errors.length === 0 ? "All good!" : errors.join(" "); });
| Step | Line |
|---|---|
| stop the reload | event.preventDefault() |
| read the fields | .value on each input |
| check them | validate({ ... }) |
| report | output.textContent = ... |
An input's current text is read with .value and not .textContent, since the typed value is not part of the element's text content.
Without event.preventDefault() the page reloads and the message flashes away instantly, which looks exactly like a script that never ran. The wiring shape is the one from this lesson's first block, almost verbatim.
Why the form opts out of built-in validation
The novalidate attribute turns off the browser's built-in checks so the custom validation and messages run instead.
Without it, the browser's own type="email" and required checks from lesson 3-1 would block submission before the listener ever gets a say.
With novalidate | Without it |
|---|---|
| the submit listener always runs | the browser may block it first |
| messages are written by the page | messages come from the browser |
Opting out is a trade rather than an upgrade, since the built-in checks are free and work with no JavaScript at all. Taking control is worth it when the messages need to match the rest of the interface, and a real signup form still validates again on the server.
The finished page, line by line
Walking the finished page top to bottom, every line traces back to a lesson.
| Part of the page | Units |
|---|---|
| the semantic skeleton | 2 and 3 |
| class styling with hover and focus states | 4 |
| border-box layout math and positioning | 5 |
| the flex header and hero | 6 |
| the responsive card grid | 7 |
| DOM wiring, events, elements from data | 8 |
| DevTools for when it misbehaves | lesson 9-1 |
Three directions are worth taking from here.
Rebuild a real page worth admiring, such as a pricing page or a docs page, from a blank file, without peeking until genuinely stuck.
Push the DOM further with a to-do list, tabs, or a modal. Each is about one afternoon with querySelector and events.
A framework such as React will then make sense quickly, because it generates the same HTML, CSS, and DOM that now read fluently.
The call that stops the reload
In the submit listener, the call that stops the browser's page-reloading form submission is event.preventDefault().
It cancels the event's default browser action, which for submit is the full-page form submission, leaving JavaScript in charge of what happens next.
| Detail | Value |
|---|---|
| called on | the event object |
| cancels | the default action only |
| leaves running | the rest of the listener |
The name says exactly what it does to the default action. It is the first line of most submit handlers for that reason, since anything after a reload never gets the chance to run.