Accessible Form Error Summary

Validate a signup form with a focusable error summary, linked error messages, aria-invalid states, and a polite success announcement.

HtmlForm Validation

Code

snippet
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Signup with an accessible error summary</title>
  <style>
    body {
      margin: 0;
      padding: 2rem;
      font-family: system-ui, sans-serif;
    }

    form {
      max-width: 28rem;
      display: grid;
      gap: 1.25rem;
    }

    .field {
      display: grid;
      gap: 0.35rem;
    }

    input {
      font: inherit;
      padding: 0.5rem;
      border: 1px solid gray;
      border-radius: 0.35rem;
    }

    input:focus-visible,
    button:focus-visible,
    .error-summary:focus-visible {
      outline: 3px solid #4f46e5;
      outline-offset: 2px;
    }

    .error-summary {
      border: 2px solid #b91c1c;
      border-radius: 0.5rem;
      padding: 1rem;
    }

    .error-summary h2 {
      margin-top: 0;
      font-size: 1.15rem;
    }

    .error-summary a {
      color: inherit;
    }

    .field-error {
      margin: 0;
      color: #b91c1c;
    }

    input[aria-invalid="true"] {
      border-color: #b91c1c;
    }

    fieldset {
      border: 0;
      padding: 0;
      display: grid;
      gap: 0.35rem;
    }
  </style>
</head>
<body>
  <main>
    <h1>Create your account</h1>

    <form id="signup" novalidate>
      <div class="error-summary" id="error-summary" role="alert" tabindex="-1" hidden>
        <h2>There is a problem</h2>
        <ul id="error-list"></ul>
      </div>

      <div class="field">
        <label for="email">Email address</label>
        <input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-error">
        <p class="field-error" id="email-error" hidden></p>
      </div>

      <div class="field">
        <label for="password">Password</label>
        <input id="password" name="password" type="password" autocomplete="new-password" minlength="8" required aria-describedby="password-error">
        <p class="field-error" id="password-error" hidden></p>
      </div>

      <fieldset aria-describedby="plan-error">
        <legend>Plan</legend>
        <label><input type="radio" name="plan" value="starter" id="plan-starter" required> Starter</label>
        <label><input type="radio" name="plan" value="pro" id="plan-pro"> Pro</label>
        <p class="field-error" id="plan-error" hidden></p>
      </fieldset>

      <div class="field">
        <label for="terms">
          <input id="terms" name="terms" type="checkbox" required aria-describedby="terms-error">
          I accept the terms of service
        </label>
        <p class="field-error" id="terms-error" hidden></p>
      </div>

      <button type="submit">Create account</button>
      <p id="status" role="status" aria-live="polite"></p>
    </form>
  </main>

  <script>
    const form = document.querySelector('#signup');
    const summary = document.querySelector('#error-summary');
    const errorList = document.querySelector('#error-list');
    const status = document.querySelector('#status');

    const email = document.querySelector('#email');
    const password = document.querySelector('#password');
    const planInputs = [...document.querySelectorAll('input[name="plan"]')];
    const terms = document.querySelector('#terms');

    const fields = [
      {
        input: email,
        error: document.querySelector('#email-error'),
        validate() {
          const value = email.value.trim();
          if (!value) return 'Enter your email address.';
          if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
            return 'Enter an email address in the format [email protected].';
          }
          return '';
        }
      },
      {
        input: password,
        error: document.querySelector('#password-error'),
        validate() {
          if (!password.value) return 'Enter a password.';
          if (password.value.length < 8) return 'Use at least 8 characters for your password.';
          return '';
        }
      },
      {
        inputs: planInputs,
        input: planInputs[0],
        error: document.querySelector('#plan-error'),
        validate() {
          return document.querySelector('input[name="plan"]:checked') ? '' : 'Choose a plan.';
        }
      },
      {
        input: terms,
        error: document.querySelector('#terms-error'),
        validate() {
          return terms.checked ? '' : 'Accept the terms to continue.';
        }
      }
    ];

    function targetsFor(field) {
      return field.inputs ?? [field.input];
    }

    function clearError(field) {
      field.error.textContent = '';
      field.error.hidden = true;
      for (const target of targetsFor(field)) {
        target.removeAttribute('aria-invalid');
      }
      if (!fields.some((candidate) => !candidate.error.hidden)) {
        summary.hidden = true;
        errorList.replaceChildren();
      }
    }

    for (const field of fields) {
      for (const target of targetsFor(field)) {
        target.addEventListener('input', () => clearError(field));
        target.addEventListener('change', () => clearError(field));
      }
    }

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      errorList.replaceChildren();
      const invalid = [];

      for (const field of fields) {
        const message = field.validate();
        field.error.textContent = message;
        field.error.hidden = message === '';
        for (const target of targetsFor(field)) {
          if (message) target.setAttribute('aria-invalid', 'true');
          else target.removeAttribute('aria-invalid');
        }
        if (message) invalid.push({ field, message });
      }

      if (invalid.length > 0) {
        for (const { field, message } of invalid) {
          const item = document.createElement('li');
          const link = document.createElement('a');
          link.href = '#' + targetsFor(field)[0].id;
          link.textContent = message;
          link.addEventListener('click', (clickEvent) => {
            clickEvent.preventDefault();
            targetsFor(field)[0].focus();
          });
          item.append(link);
          errorList.append(item);
        }
        summary.hidden = false;
        summary.focus();
        status.textContent = '';
        return;
      }

      summary.hidden = true;
      status.textContent = 'Account created. Check your inbox to confirm your email.';
      form.reset();
    });
  </script>
</body>
</html>