← 中文资源

简体中文版本

无障碍表单错误摘要

提交时汇总所有校验错误,把焦点移到可聚焦的摘要,并通过 aria-invalid 与描述关联定位到具体字段。

阅读英文原文

功能说明

注册表单包含邮箱、密码、单选方案与条款复选框,使用 novalidate 关闭浏览器原生气泡,改由脚本统一校验。提交失败时生成错误摘要,逐条链接到对应输入并聚焦;每个字段设置 aria-invalid 与内联错误文本,错误摘要本身带 role=“alert” 和 tabindex=“-1”。全部通过时隐藏摘要,并用 role=“status” 宣布账户创建成功。

关键代码

摘要容器初始 hidden,失败后先清空列表再逐条创建 li 与 a,避免复用旧节点或注入 HTML。链接的 href 指向输入 id,点击时阻止默认跳转并直接聚焦目标,兼顾鼠标与键盘。输入事件与 change 事件都会清除该字段错误,让用户修正后立即获得反馈。单选组把 aria-describedby 指向组级错误,聚焦第一个选项。成功时 form.reset 清空状态。

使用注意

role=“alert” 与主动聚焦可能造成屏幕阅读器重复播报,不同组合的体验有差异,应在目标读屏器上验证。错误文本必须具体说明如何修复,不能只写“无效”。客户端校验只是体验优化,服务端必须重复校验。错误摘要出现后不要自动滚动到页面顶部而丢失上下文。复选框的标签与错误段落要保持正确关联。

验证方式

直接提交空表单,焦点应移到错误摘要,列出四条错误;按 Tab 可在链接间移动,按回车跳到对应字段。修正邮箱后其错误应立即消失。填写全部字段并勾选条款,提交后摘要隐藏并显示成功消息。关闭 JavaScript 时表单仍可提交,但只能依赖服务端返回错误。

代码示例

<!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>