Webbo3 Level 2 · JavaScript Development · Day 13 of 20
Forms, UX and Accessibility
The day you unplug your mouse. Validation people can actually act on, and an interface that works for everybody who visits it.
By the end of today you can
- Validate a form and say what is wrong in a way the user can fix
- Use the browser's own validation instead of rewriting it
- Operate your entire application with the keyboard alone
- Manage focus so a keyboard user is never lost
- Announce changes that happen without a page reload
- Know when a
divshould have been abutton, and why it matters
This is the lesson most bootcamps skip, and skipping it is why so much of the web is unusable for a lot of people. It is also, increasingly, a legal requirement, and it is one of the few things a junior developer can be noticeably better at than the team they join. An hour today is worth a great deal later.
1. The browser validates for free
<form novalidate>
<label for="email">Email address</label>
<input id="email" name="email" type="email" required
autocomplete="email" aria-describedby="email-error">
<p id="email-error" class="error" role="alert"></p>
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" inputmode="numeric"
pattern="[0-9]{11}" autocomplete="tel">
<button type="submit">Create account</button>
</form>| Attribute | What it buys you |
|---|---|
type="email" | Validation, and the right keyboard on a phone |
type="tel" + inputmode="numeric" | A number pad instead of a keyboard |
required | The browser blocks submission and focuses the field |
pattern | A regular expression check with no JavaScript |
autocomplete | The browser fills it in: a huge usability win |
novalidate | Turn the browser's own messages off so you can write better ones |
The Constraint Validation API then lets you keep the browser's checking and replace only its wording, which is the best of both.
form.addEventListener('submit', (event) => {
event.preventDefault();
let firstBad = null;
for (const field of form.elements) {
if (!field.name) continue;
const error = document.querySelector(`#${field.name}-error`);
if (field.validity.valid) {
field.removeAttribute('aria-invalid');
if (error) error.textContent = '';
continue;
}
field.setAttribute('aria-invalid', 'true');
if (error) error.textContent = messageFor(field);
firstBad = firstBad ?? field;
}
if (firstBad) { firstBad.focus(); return; } // send them to the problem
submit();
});
function messageFor(field) {
if (field.validity.valueMissing) return `${label(field)} is required.`;
if (field.validity.typeMismatch) return `That does not look like an email address.`;
if (field.validity.patternMismatch) return `Enter 11 digits, no spaces.`;
return 'Please check this field.';
}Focusing the first bad field is not a nicety
On a long form on a phone, an error message eight fields above the fold is an error message nobody sees. They press submit, nothing appears to happen, and they leave. firstBad.focus() is two words and it is the difference between a form that gets completed and one that gets abandoned.
Error messages that help
| Instead of | Write |
|---|---|
| Invalid input | Enter 11 digits, with no spaces |
| Error | That email is already registered. Sign in instead? |
| Password too weak | Add one number. Passwords need 8 characters and a digit |
| Required field | We need your phone number to confirm the delivery |
The test: does the message tell them what to do next? "Invalid" describes your code's opinion. "Enter 11 digits" describes their next action.
2. Unplug your mouse
Do this before reading further
Open your Day 5 or Day 9 project. Put the mouse away, genuinely. Now use the entire application with Tab, Shift+Tab, Enter, Space and the arrow keys. Write down every place you get stuck. Most people find three or four in five minutes, and it is a sobering exercise the first time.
| Key | Should do |
|---|---|
Tab | Move to the next interactive thing, in a sensible order |
Shift+Tab | Move back |
Enter | Activate a link or a button, submit a form |
Space | Activate a button, tick a checkbox, scroll |
Escape | Close the modal or the dropdown |
| Arrows | Move within a radio group, a menu or a slider |
3. The div-that-should-be-a-button
<!-- looks like a button. Is not one. -->
<div class="btn" onclick="save()">Save</div>
<!-- is a button -->
<button type="button" class="btn">Save</button>That div cannot be focused with Tab, does not respond to Enter or Space, is not announced as a button by a screen reader, and shows no focus ring. To make it behave you would have to add tabindex="0", a role, and keyboard handlers for two keys: four things the <button> element already does. Use the right element and accessibility is mostly free.
| If it | Use |
|---|---|
| Goes to another page or URL | <a href="..."> |
| Does something on this page | <button type="button"> |
| Submits the form | <button type="submit"> |
| Is a form control | <input>, <select>, <textarea>, with a <label> |
| Is just a box | <div> |
The default button type is submit
A <button> inside a form with no type attribute submits the form. Put a Delete button inside a form without type="button" and clicking it reloads the page. This is a genuinely common bug and the fix is one word.
4. Focus, and where it goes
When a modal opens, focus must go into it. When it closes, focus must go back to whatever opened it. Otherwise a keyboard user closes a dialog and finds themselves back at the top of the document with no idea where they are.
let lastFocused = null;
function openDialog() {
lastFocused = document.activeElement; // remember where we came from
dialog.hidden = false;
dialog.querySelector('button, [href], input, select, textarea')?.focus();
document.addEventListener('keydown', onEscape);
}
function closeDialog() {
dialog.hidden = true;
document.removeEventListener('keydown', onEscape);
lastFocused?.focus(); // put them back
}
const onEscape = (event) => { if (event.key === 'Escape') closeDialog(); };Never remove the focus ring
outline: none is in more stylesheets than almost any other rule, and it makes an interface impossible to navigate by keyboard: you cannot see where you are. If the default ring is ugly, replace it with something visible that matches your design. Use :focus-visible so it shows for keyboard users without appearing on every mouse click.
/* never this */
button:focus { outline: none; }
/* this */
button:focus-visible {
outline: 3px solid #0a6e6e;
outline-offset: 2px;
}5. Telling a screen reader what changed
When you update the page with JavaScript, a sighted user sees it. A screen reader user is told nothing at all, because nothing was navigated to. A live region fixes that: anything that appears inside it is announced.
<!-- polite: waits for a pause. Right for search result counts -->
<p id="status" role="status" aria-live="polite"></p>
<!-- assertive: interrupts. Only for genuine errors -->
<p id="error" role="alert"></p>status.textContent = `${results.length} students found`;- The element must already be in the DOM when the page loads. Creating the live region and its content at the same moment announces nothing.
role="status"waits for a natural pause.role="alert"interrupts, so save it for errors: an interface that interrupts constantly is unusable.
6. The ten-minute audit
Run this on every project you ship from now on. It is not comprehensive, and it catches most of what a beginner gets wrong.
- Tab through it. Can you reach everything, in a sensible order, and always see where you are?
- Every image: does the
altsay what it shows, or is italt=""because it is decorative? "image1.jpg" is worse than nothing. - Every input: does clicking its label focus the field? If not,
forandiddo not match. - Headings:
h1thenh2thenh3, in order, no skipping. A screen reader user navigates by them the way you navigate by eye. - Colour contrast: 4.5:1 for body text, as you learned last month. DevTools shows you the ratio when you inspect a colour.
- Colour alone: is any information conveyed only by colour? Add an icon or a word for the eight per cent of men who are colour blind.
- Zoom to 200%. Does anything overlap or disappear?
- Run Lighthouse. DevTools, Lighthouse tab, tick Accessibility. It is not the whole answer and it will find real problems.
Do this now
- Do the keyboard audit on your project and write down every problem.
- Fix them. Expect the list to include a div that should be a button, a missing label, and a removed focus ring.
- Rewrite every validation message so it says what to do next.
- Add a live region announcing your result count.
- Run Lighthouse and get Accessibility above 95. Then read what it still complains about, because the last few points teach you the most.
Checkpoint
Q1. Why is a clickable `div` worse than a `button`?
It cannot be tabbed to, does not respond to Enter or Space, is not announced as a button, and has no focus ring. The button element does all four without you writing anything.
Q2. A Delete button inside a form reloads the page. Why?
A <button> with no type defaults to type="submit". Add type="button".
Q3. What is wrong with `outline: none`?
It hides the only indication of where keyboard focus is, which makes the interface impossible to navigate without a mouse. Replace the ring, do not remove it, and use :focus-visible.
Q4. Why does focus need to return when a dialog closes?
Because otherwise focus falls back to the top of the document and the keyboard user loses their place entirely, with no visual cue about what happened.
Tick before you move on
- ☐ I used my whole application with no mouse and fixed everything I found
- ☐ Every interactive element is the correct HTML element
- ☐ Focus is visible everywhere and returns correctly after a dialog
- ☐ Every validation message says what to do next
- ☐ Lighthouse Accessibility is above 95
Quick recap
The browser validates for free; replace only its wording, not its checking · Focus the first invalid field, or the error is never seen · Right element, free accessibility: button for actions, a for navigation · Never remove the focus ring, replace it, and use :focus-visible · Live regions announce what a sighted user can simply see
Tomorrow, Day 14: finding bugs instead of guessing at them, and the difference between code that works and code that is fast.