Validation, accessibility and deploying it
The page works and survives a reload. This lesson is the difference between that and something you would put your name to: validation that helps, a page usable without a mouse, a layout that works in a kitchen, and a URL you can send somebody.
Step 1: validation that says what to do
src/validate.js:
const PINCODE = /^[1-9]\d{5}$/; // six digits, no leading zero
const PHONE = /^[6-9]\d{9}$/; // ten digits, starting 6 to 9
export function validateOrder(raw, mealIds) {
const errors = {};
const customer = raw.customer.trim();
if (customer === '') {
errors.customer = 'Please enter a name.';
} else if (customer.length > 60) {
errors.customer = 'That name is too long.';
}
const phone = raw.phone.replace(/\s|-/g, '');
if (!PHONE.test(phone)) {
errors.phone = 'Ten digits, starting 6 to 9.';
}
const pincode = raw.pincode.trim();
if (!PINCODE.test(pincode)) {
errors.pincode = 'Six digits, not starting with zero.';
}
const platesText = raw.plates.trim();
const plates = Number(platesText);
if (platesText === '' || Number.isNaN(plates)) {
errors.plates = 'Enter how many plates.';
} else if (!Number.isInteger(plates) || plates < 1 || plates > 50) {
errors.plates = 'Between 1 and 50 whole plates.';
}
if (!mealIds.includes(raw.meal)) {
errors.meal = 'Choose a meal.';
}
const ok = Object.keys(errors).length === 0;
return {
ok,
errors,
value: ok ? { customer, phone, pincode, plates, meal: raw.meal } : null,
};
}
Five things, each from an earlier module.
Neither pattern has a g flag. A /g regex used with .test() alternates
true and false on the same string, so every other order would be rejected with a
pattern that is obviously correct.
The phone is normalised before testing. Anna will type 98765 43210, and
rejecting that would be the page being pedantic rather than careful.
Empty is checked before converting, because Number('') is 0 — and a
number input given letters hands back '', not the letters. Without that check,
typing "abc" into plates would silently become an order for zero.
The messages say what to do. "Six digits, not starting with zero" beats "Invalid pincode", and costs nothing.
It returns value with plates as a number. This function is the boundary;
after it, nothing is a string that should not be.
And the caller focuses the first bad field:
const first = FIELDS.find((field) => field in errors);
if (first) document.querySelector(`#${first}`).focus();
Five red messages with no idea where to start is a page that has technically told you and practically has not.
Step 2: clear errors as they are fixed
form.addEventListener('input', (event) => {
const field = event.target.id;
if (FIELDS.includes(field)) {
document.querySelector(`#${field}-error`).textContent = '';
document.querySelector(`#${field}`).removeAttribute('aria-invalid');
}
});
One delegated listener on the form, using input so it fires as they type. An
error that stays up while you correct it makes the page feel broken.
Step 3: usable without a mouse
Four things, none of which take long, all of which are routinely skipped.
Every input already has a <label for="...">. That is what lets a screen
reader announce the field, and it makes the label a tap target — which matters on
a phone.
aria-invalid on a failed field, set with the message and removed when
fixed. A screen reader then says the field is invalid rather than only drawing it
red.
role="status" on the two status lines — the rates line and the list line.
Changes to them are announced without stealing focus, so "No orders match that
search" reaches somebody who cannot see it.
A visible focus ring. Never outline: none without a replacement:
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
Test it by unplugging your mouse. Tab through the whole page: every field, every filter, every checkbox, every Remove button. You should always be able to see where you are and reach everything.
An icon-only button needs a label too:
remove.setAttribute('aria-label', `Remove ${order.customer}'s order`);
"Remove" repeated twelve times is useless when you are listening rather than looking.
Step 4: the phone
.order {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.25rem 0.625rem;
align-items: center;
}
.order input[type='checkbox'] { grid-column: 1; grid-row: 1; }
.order .name { grid-column: 2; grid-row: 1; }
.order button { grid-column: 3; grid-row: 1; }
.order .detail { grid-column: 2 / -1; grid-row: 2; }
Checkbox, name and Remove on one line; the details underneath. Placing the grid items explicitly is what stops Remove dropping onto its own row when the detail text wraps — which is exactly what happened before these four lines were added, and is the kind of thing you only see by looking at 390px.
Check, at 390px:
- No horizontal scrolling.
document.documentElement.scrollWidthshould equalclientWidth. Measure it; a sideways-scrolling page looks fine in a screenshot of its top. - Tap targets around 44px. A 1.1rem checkbox with padding around it, not a default 13px one.
inputmode="numeric"on phone and pincode, so the number keypad opens.
And a dark mode is a handful of lines if your colours are already tokens:
@media (prefers-color-scheme: dark) {
:root {
--ink: #e8eaed;
--bg: #14181d;
}
}
Step 5: the last look at the code
Before deploying, three passes.
Remove every console.log. They are visible to anyone who opens devtools,
and a log of a customer's phone number is a leak. Keep console.error in the
catch — that one is doing a job.
Remove any debugger statement.
Read each file and ask what it is for. If you cannot say in one sentence, it is doing two things.
Then use it as Anna would: add six orders, mark some delivered, search, reload, turn the network off. Throttle to Slow 3G and load it again — that is the kitchen on mobile data, and it is where you find out whether your loading state was decoration or necessary.
Step 6: deploy
It is static files, so hosting is free and takes minutes. Put it on GitHub first — a repository is part of the portfolio piece, not an afterthought.
git init -b main
git add .
git commit -m "Tiffin order tracker"
Then either:
Netlify — drag the folder onto the dashboard, or connect the repository.
Vercel — import the repository. No build command and no output directory; it is static.
GitHub Pages — Settings, Pages, deploy from main. Free and closest to the
code.
Check the deployed URL, not just localhost. The two ways this fails:
A wrong path. data/rates.json must be relative, not /data/rates.json, if
the site is served from a subfolder — which GitHub Pages does by default. This is
the commonest deployment failure for a static site.
Case sensitivity. Your Mac does not care that the file is Rates.json and
the code says rates.json. The Linux server hosting it does. A 404 that only
happens in production is almost always this.
Open the deployed URL on your actual phone, add an order, and reload. Then send it to somebody.
Step 7: the README
The repository needs one, because it is the first thing anybody reads:
- What it is, in one sentence.
- How to run it — and that it needs a server, because ES modules will not load from the file system.
- The decisions worth knowing: money in paise, dates from local parts, state
as the single source of truth,
textContentthroughout. - What it does not do, and why. Naming the limits deliberately reads as judgement; leaving them unmentioned reads as an oversight.
Check your work
Neither regex carries g, or .test() would alternate and reject every
other order.
The phone is normalised before testing, so 98765 43210 is accepted.
Empty is checked before converting, because Number('') is 0 and a number
input given letters returns ''.
Validation returns plates as a number. That function is the boundary.
The first invalid field is focused.
Errors clear on input, via one delegated listener.
Labels, aria-invalid, role="status" and a visible focus ring — and an
aria-label on each Remove button naming whose order it is.
Grid items are placed explicitly, or Remove drops to its own row when the detail wraps.
No horizontal scroll at 390px — measured, not eyeballed.
Every console.log and debugger removed; console.error in the catch
stays.
Relative asset paths and matching filename case, the two things that work locally and 404 in production.
Practice
- Add the full validation and test each rule: an empty name, a phone starting with 5, a pincode starting with 0, 0 plates, 51 plates, and letters in the plate field.
- Type
98765 43210and confirm it is accepted. - Put a
gon one of the patterns and submit the same order twice. Watch the second be rejected. Remove it. - Submit an empty form and confirm focus lands on the first bad field.
- Unplug your mouse. Complete an order, mark it delivered and remove it using only the keyboard. Fix whatever you could not reach.
- Turn on a screen reader for five minutes and listen to your own page. It is uncomfortable and it is the fastest way to find what is missing.
- Check at 390px that nothing scrolls sideways — measure
scrollWidthagainstclientWidthrather than trusting a screenshot. - Throttle to Slow 3G and load the page. Watch your own loading state do its job.
- Remove every
console.log, then search the project to be sure. - Deploy it. Open the deployed URL on your phone. Add an order and reload.
- Break the deployment on purpose: change
data/rates.jsonto/data/rates.jsonand redeploy to a subfolder host. Watch the 404 that never happened locally. Fix it. - Write the README.
- Harder. Add editing — tapping a row opens it in the form, and saving updates rather than adds. Keep the rule: update state, save, re-render. Decide what Cancel does, and what happens if the meal that order used is no longer on the rate card. That second question is the interesting one, and the code already has an opinion about it.
That is the course. You have built an application that fetches data, validates input, keeps state, survives a reload, degrades when the network fails, works on a phone, and is deployed at a URL you can send someone.
Look back at module 1. You did not know what the DOM was, why "5" + 2 was
"52", or what a promise did. The three ideas that carried the whole way
through are worth naming one last time:
Convert at the boundary. Strings from forms and networks become real values once, at the edge, and are correct everywhere after.
State is the truth; the page is a picture of it. Every framework you learn next is an implementation of that sentence.
Write the failure states first. They are what the user gets on a train, and they are what separates somebody who finished a tutorial from somebody who can be given work.
Where to go next: TypeScript, which exists because of the dynamic-typing cost named in module 2 — it is the natural next course. Then React, which will now read as a convenience rather than magic. Or the Full-Stack course, which puts a real server behind exactly the kind of page you just built, and answers every "this should really be on a server" this module admitted to.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship