Skip to content
academia.sh

Lesson 25 / 25

Form Accessibility

Binding error messages to a field, building an error summary returned from the server, focus management, and the requirements of keyboard use.

Contents

At this point the form collects data, validates it, and can accept a file. One question remains: what does the user see when submission is rejected?

The Built-in Validation lesson noted two limits of the browser’s own message: it shows only one field at a time, and can disappear quickly. An error returned from the server, on the other hand, arrives as a new document, and the user has to work out which fields need fixing.

This lesson builds error reporting into the document. The structure built needs no script; it is markup produced by the server, and so it works under every condition.

What an Error Report Must Meet

There are four requirements, and all four are met with markup.

Findability. The user must see where on the page they landed and how many errors there are. A single red line in the middle of the page tells a user who is not looking at that line nothing.

Binding. Each message must be bound to the field it belongs to through a declaration. Text placed under a field is visually bound; it is not bound in the accessibility tree.

Announceability. When the field receives focus, the error message must be announced too.

Reachability. Reaching the message must not require guessing which field the error is in.

It should also be added that color alone is not a report: a red border tells a user who cannot distinguish the color nothing. The state must also be reported with text.

The Error Summary Returned From the Server

The script below builds the document the server produces after validation. The input is the field definitions and the error list; the output is the markup to be sent.

// accessibility.mjs — the error summary produced on the server, and the field-error bonds
const FIELDS = [
  { id: "station", name: "station", label: "Station code", type: "text" },
  { id: "temperature", name: "temperature", label: "Temperature (°C)", type: "number" },
  { id: "email", name: "email", label: "Notification address", type: "email" },
];

const MESSAGES = {
  valueMissing: "This field is required.",
  patternMismatch: "Use lowercase letters and hyphens only.",
  rangeOverflow: "The value can be at most 60.",
  typeMismatch: "Enter a valid email address.",
};

function escapeHtml(text) {
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function renderForm(values, errors) {
  const lines = [];
  if (errors.length > 0) {
    lines.push('<div role="alert" tabindex="-1" id="error-summary">');
    lines.push("  <h2>Form could not be submitted: " + errors.length + " fields need to be fixed</h2>");
    lines.push("  <ul>");
    for (const [fieldId, code] of errors) {
      const field = FIELDS.find((f) => f.id === fieldId);
      lines.push(
        '    <li><a href="#' + field.id + '">' + escapeHtml(field.label) + ": " + escapeHtml(MESSAGES[code]) + "</a></li>",
      );
    }
    lines.push("  </ul>");
    lines.push("</div>");
  }
  lines.push('<form action="/record" method="post" novalidate>');
  for (const field of FIELDS) {
    const error = errors.find(([id]) => id === field.id);
    lines.push('  <p><label for="' + field.id + '">' + escapeHtml(field.label) + "</label>");
    const attrs = [
      'id="' + field.id + '"',
      'name="' + field.name + '"',
      'type="' + field.type + '"',
      'value="' + escapeHtml(values[field.name] ?? "") + '"',
    ];
    if (error) {
      attrs.push('aria-invalid="true"');
      attrs.push('aria-describedby="' + field.id + '-error"');
    }
    lines.push("     <input " + attrs.join(" ") + ">");
    if (error) {
      lines.push(
        '     <span id="' + field.id + '-error" class="error">' + escapeHtml(MESSAGES[error[1]]) + "</span>",
      );
    }
    lines.push("  </p>");
  }
  lines.push('  <p><button type="submit">Submit record</button></p>');
  lines.push("</form>");
  return lines.join("\n");
}

const values = { station: "North Slope", temperature: "95", email: "[email protected]" };
const errors = [["station", "patternMismatch"], ["temperature", "rangeOverflow"]];
console.log(renderForm(values, errors));
<div role="alert" tabindex="-1" id="error-summary">
  <h2>Form could not be submitted: 2 fields need to be fixed</h2>
  <ul>
    <li><a href="#station">Station code: Use lowercase letters and hyphens only.</a></li>
    <li><a href="#temperature">Temperature (°C): The value can be at most 60.</a></li>
  </ul>
</div>
<form action="/record" method="post" novalidate>
  <p><label for="station">Station code</label>
     <input id="station" name="station" type="text" value="North Slope" aria-invalid="true" aria-describedby="station-error">
     <span id="station-error" class="error">Use lowercase letters and hyphens only.</span>
  </p>
  <p><label for="temperature">Temperature (°C)</label>
     <input id="temperature" name="temperature" type="number" value="95" aria-invalid="true" aria-describedby="temperature-error">
     <span id="temperature-error" class="error">The value can be at most 60.</span>
  </p>
  <p><label for="email">Notification address</label>
     <input id="email" name="email" type="email" value="[email protected]">
  </p>
  <p><button type="submit">Submit record</button></p>
</form>

The markup produced meets each of the four requirements.

The summary sits before the form and states with a heading how many fields need to be fixed. Sitting at the top of the document ensures the user reaches it without extra navigation; the role="alert" declaration marks the section as a region to be announced immediately. Whether it is announced while the document loads depends on the implementation, so the summary does not rely on the announcement alone: it is also findable by its position and heading.

Every error is a link, and its target is the relevant field’s id. The link text does not just say “this field is required” — it also says which field it is; the “understandable when taken out of context” criterion from the Links lesson applies here too.

Fields carry aria-invalid="true". This is an invalidity declaration to the accessibility tree, and carries information color does not.

The message is bound to the field with aria-describedby. The mechanism built in the Label-Field Relationship lesson is used here for the error message: when the field receives focus, the name is announced, then the type, then the description.

Note that entered values are preserved. Even invalid values are written back into the fields; the user sees what they typed and corrects it. Returning the form emptied destroys the user’s work on long forms.

Escaping is also required: field values come from the user, and as noted in the Tags, Attributes, and Entities lesson, writing <, &, and quote characters into the document unescaped breaks the structure.

Focus Management

The summary carries a tabindex="-1" attribute so that it can be focused programmatically. A value of negative one keeps the element out of the tab order but still lets it receive focus.

In a document returned from the server, focus starts at the top of the document and the summary is already there; no extra step is needed. In checks made without reloading the page, focus must be moved to the summary, or the user stays unaware of the newly added section.

When the link in the summary is clicked, focus goes to the target field. All that is needed for this is that the field be focusable; input fields already are.

Values of tabindex greater than zero are not used. These values move the element to the front of the tab order and break the order of the rest of the document; the order should come from the document’s source order. When focus order and visual order diverge, a keyboard user jumps around the screen.

Reporting Without a Page Reload

If the check is done without reloading the page, a message that appears on screen needs to be announced. A live region is a section whose content is announced by a screen reader when it changes.

<div role="alert" id="error-summary" tabindex="-1"></div>

A role="alert" declaration demands an immediate announcement and interrupts the user’s current reading; this suits error reports. For less urgent information — “submitting record,” for instance — a live region that announces without interrupting is used instead.

The live region must exist in the document beforehand. In some implementations, the content of a live region added to the document afterward is not announced; an empty container is placed beforehand and its content is filled in later.

Keyboard Use

A form being usable by keyboard is provided automatically as long as nothing is done in the document to prevent it: the input, select, textarea, and button elements are focusable, tab order is source order, and submission can be made with the enter key while in one of the fields.

The places where this behavior breaks are places where the language’s own elements are replaced with something else. A button built by adding a click behavior to a div element is not focusable, cannot be pressed by keyboard, and is not announced as a button by a screen reader. The declarations needed to win these back — focusability, role, key handling — already exist on the button element.

The focus indicator is not removed either. When the focused element has no visible mark, a keyboard user cannot tell where in the document they are. The indicator can be changed in the presentation layer; it cannot be removed.

The Finished Form

The final section of the document developed throughout the course:

<section aria-labelledby="notice">
  <h2 id="notice">Report a Measurement Correction</h2>

  <div role="alert" id="error-summary" tabindex="-1"></div>

  <form action="/measurement/correction" method="post" enctype="multipart/form-data">
    <fieldset>
      <legend>Measurement information</legend>

      <p><label for="station">Station code</label>
         <input id="station" name="station" type="text" value="north-slope"
                required maxlength="20" pattern="[a-z-]+"
                aria-describedby="station-help">
         <span id="station-help">Lowercase letters and hyphens only.</span></p>

      <p><label for="date">Measurement date</label>
         <input id="date" name="date" type="date" required></p>

      <p><label for="value">Measured value (°C)</label>
         <input id="value" name="value" type="number"
                required min="-60" max="60" step="0.1"></p>
    </fieldset>

    <fieldset>
      <legend>Measurement source</legend>
      <p><label><input type="radio" name="source" value="automatic" checked> Automatic</label></p>
      <p><label><input type="radio" name="source" value="manual"> Entered manually</label></p>
    </fieldset>

    <fieldset>
      <legend>Raw record</legend>
      <p><label for="record">Measurement file</label>
         <input id="record" name="record" type="file"
                accept=".csv,text/csv" aria-describedby="record-help">
         <span id="record-help">Comma-separated value file, 5 MB maximum.</span></p>
    </fieldset>

    <p><button type="submit">Submit correction</button></p>
  </form>
</section>

Every field has a label, every group has a name, every constraint has a declaration, and every description has a bond. There is not a single script in the document.

Summary

  • An error report must be findable, bound to its field, announceable, and reachable; color alone is not a report.
  • An error summary returned from the server sits before the form, states the error count, and presents each error as a link bound to its field.
  • Fields announce invalidity with aria-invalid and the error message with aria-describedby; entered values are preserved and written escaped.
  • tabindex="-1" makes an element focusable without putting it in the tab order; values greater than zero break the document’s focus order.
  • Live regions must exist in the document beforehand; the content of a region added afterward may not be announced.
  • Keyboard use is provided automatically as long as the language’s own elements are used; the focus indicator can be changed but not removed.

Course Wrap-Up

This course opened with a single question: what is the response body that comes back from the server? The response is a document, and the document is turned from bytes into a tree of nodes.

Four topics built this tree. How the Web Works traced the conversion from bytes to characters, from characters to tokens, from tokens to a tree; it showed the parser’s error recovery rules and how resource loading holds up this chain. Document Structure built the document’s skeleton from the root structure to tables, and repeated that every tag is a structural declaration, not a presentation choice. Semantic Markup and Media added a top-level map to the document and placed images, audio, video, and embedded content in an accessible way. Forms turned the one-way document into a two-way one, and showed through real bodies how data coming from the user reaches the server.

The measurement-station document followed throughout the course grew from a two-line skeleton into a whole page: sectioned, tabled, carrying media, and containing a validatable form. There is not a single presentation decision in the document: nowhere is a color, a measurement, a position declared.

This absence is deliberate. Of the three layers defined in the first lesson, only the first has been built. The document is readable and navigable right now when opened in a browser — because the browser carries default styles for every element. But that appearance is not the document’s decision, it is the browser’s.

The next course, Visual Presentation with CSS, builds the second layer: the selectors that decide which rule applies to which element, how conflicting rules are resolved, the box model, and flow layout. This is the document that course will work on top of — and a well-built structure is the precondition for good presentation: giving an element style first requires that element to exist.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close