После отправки форма выделяет поля красным, а человек с клавиатурой остаётся на кнопке и ищет причину отказа. Добавим сводку ошибок, ссылки на поля и связанные пояснения. Проверим тот же путь после серверного ответа.

Рабочий пример — запись на консультацию: два поля, имя и почта, и один путь исправления для клиентских и серверных ошибок. Нужны основы HTML и JavaScript, Node.js 24 или новее. Для страницы и сервера хватит встроенных возможностей платформы: отдельные библиотеки устанавливать не потребуется. В конце вы сможете отправить пустую форму, перейти из сводки к ошибочному полю, исправить его и повторить отправку без мыши.

Задайте понятные подписи до появления ошибки

Создайте папку accessible-errors. В index.html разместите разметку ниже. Подпись отвечает на вопрос, что вводить, подсказка уточняет формат. Сообщение ошибки объяснит, что именно исправить, когда поле не пройдёт проверку.

<!doctype html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Запись на консультацию</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <main>
    <h1>Запись на консультацию</h1>
    <p>Оба поля обязательны. Значения сохранятся при ошибке.</p>
    <form novalidate>
      <section id="error-summary" tabindex="-1" hidden
        aria-labelledby="error-heading">
        <h2 id="error-heading"></h2>
        <ul id="error-list"></ul>
      </section>

      <label for="name">Имя</label>
      <input id="name" name="name" required minlength="2" maxlength="80"
        autocomplete="name" aria-describedby="name-hint">
      <p id="name-hint">От двух до 80 символов</p>
      <p id="name-error" class="error"></p>

      <label for="email">Почта</label>
      <input id="email" name="email" type="email" required maxlength="200"
        autocomplete="email" aria-describedby="email-hint">
      <p id="email-hint">Например, reader@example.test</p>
      <p id="email-error" class="error"></p>

      <div id="server-problem" role="alert"></div>
      <p id="result" role="status"></p>
      <button type="submit">Записаться</button>
    </form>
  </main>
  <script type="module" src="/app.js"></script>
</body>
</html>

label for="email" связывается с id="email": подпись становится доступным именем поля. placeholder такую постоянную подпись не заменяет. Обязательность передаёт required, а видимый текст заранее объясняет правило всем читателям.

В aria-describedby перечисляются идентификаторы пояснений через пробел. Сначала поле связано только с подсказкой. При ошибке добавим идентификатор сообщения. Название атрибута не переводится; оно сообщает вспомогательной технологии, откуда взять описание элемента.

Сводка пока скрыта. tabindex="-1" разрешает перевести на неё фокус из JavaScript, сохраняя обычный порядок Tab по интерактивным элементам. Положительные значения tabindex здесь не нужны: они сделали бы порядок обхода труднее предсказуемым.

Создайте style.css. Рамка фокуса сохраняется и для полей, и для программно сфокусированной сводки. Цвет ошибки дублируется текстом.

body { margin: 1.5rem; font: 1.1rem/1.5 system-ui; }
main { max-width: 38rem; margin-inline: auto; }
label, input, button { display: block; }
label { margin-block-start: 1.5rem; }
input, button { font: inherit; padding: .6rem; }
input { inline-size: 100%; box-sizing: border-box; min-inline-size: 0; }
p { overflow-wrap: anywhere; }
:focus-visible, #error-summary:focus {
  outline: 3px solid #1249bc;
  outline-offset: 4px;
}
#error-summary { border: 2px solid #a00020; padding: 1rem; }
[aria-invalid="true"] { border: 2px solid #a00020; }
.error, #server-problem { color: #8b001d; }
button { margin-block-start: 1.5rem; }
button[aria-disabled="true"] { opacity: .65; }

Покажите сводку и свяжите её с полями

В app.js добавьте код целиком. Объект errors хранит сообщения по разрешённым именам полей. Сводка и сообщения у полей строятся из одного объекта, поэтому текст и состояние не расходятся.

const form = document.querySelector('form');
const fields = {
  name: document.querySelector('#name'),
  email: document.querySelector('#email')
};
const labels = { name: 'Имя', email: 'Почта' };
const summary = document.querySelector('#error-summary');
const heading = document.querySelector('#error-heading');
const list = document.querySelector('#error-list');
const problem = document.querySelector('#server-problem');
const result = document.querySelector('#result');
const button = document.querySelector('button');
let errors = {};
let pending = false;

function localErrors() {
  const next = {};
  const name = fields.name.value.trim();
  if (name.length < 2 || name.length > 80) {
    next.name = 'Введите имя от двух до 80 символов';
  }
  if (!fields.email.checkValidity()) {
    next.email = 'Введите почту в формате reader@example.test';
  }
  return next;
}

function renderErrors() {
  list.replaceChildren();
  for (const [key, field] of Object.entries(fields)) {
    const message = errors[key];
    document.querySelector(`#${key}-error`).textContent = message || '';
    field.setAttribute('aria-describedby', `${key}-hint${message ? ` ${key}-error` : ''}`);
    if (message) field.setAttribute('aria-invalid', 'true');
    else field.removeAttribute('aria-invalid');
    if (message) {
      const item = document.createElement('li');
      const link = document.createElement('a');
      link.href = `#${key}`;
      link.textContent = `${labels[key]}: ${message}`;
      link.addEventListener('click', event => {
        event.preventDefault();
        field.focus();
      });
      item.append(link);
      list.append(item);
    }
  }
  const count = Object.keys(errors).length;
  summary.hidden = count === 0;
  heading.textContent = count === 1 ? 'Исправьте одно поле' : `Исправьте поля: ${count}`;
}

for (const [key, field] of Object.entries(fields)) {
  field.addEventListener('input', () => {
    if (!errors[key]) return;
    if (!localErrors()[key]) {
      delete errors[key];
      renderErrors();
    }
  });
}

form.addEventListener('submit', async event => {
  event.preventDefault();
  if (pending) return;
  problem.textContent = '';
  result.textContent = '';
  errors = localErrors();
  renderErrors();
  if (Object.keys(errors).length) {
    summary.focus();
    return;
  }
  pending = true;
  button.setAttribute('aria-disabled', 'true');
  button.textContent = 'Отправляем…';
  Object.values(fields).forEach(field => { field.readOnly = true; });
  try {
    const response = await fetch('/api/consultation', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: fields.name.value.trim(), email: fields.email.value.trim()
      })
    });
    const data = await response.json();
    if (response.status === 422 && data.fields) {
      errors = {};
      for (const key of Object.keys(fields)) {
        if (typeof data.fields[key] === 'string') errors[key] = data.fields[key];
      }
      if (!Object.keys(errors).length) throw new Error('Unknown server error');
      renderErrors();
      summary.focus();
    } else if (!response.ok) {
      throw new Error('Request failed');
    } else {
      result.textContent = 'Заявка принята';
    }
  } catch {
    problem.textContent = 'Не удалось отправить. Проверьте связь и повторите попытку';
  } finally {
    pending = false;
    button.removeAttribute('aria-disabled');
    button.textContent = 'Записаться';
    Object.values(fields).forEach(field => { field.readOnly = false; });
  }
});

При пустой отправке сначала обновляется DOM, затем фокус переходит к сводке. Человек получает точку начала исправлений. Нажмите Tab, чтобы перейти к первой ссылке, затем Enter, чтобы сфокусировать соответствующее поле. Длинной форме сводка помогает обнаружить ошибки вне текущего экрана.

У сводки нет role="alert": в этом варианте начало исправления сообщает перевод фокуса. Если одновременно сделать сводку живой областью, объявлять каждое поле и переводить фокус, диктор может повторять сообщения. Выберите согласованную стратегию и проверьте её реальным озвучиванием.

role="alert" оставлен для общей асинхронной ошибки сети. Эту ошибку нельзя исправить вводом в конкретное поле. Сообщение об успехе попадёт в постоянно существующий role="status", чтобы диктор мог объявить его без принудительного перехода к новому месту страницы. Обе области существуют до появления текста: код меняет их содержимое.

Кнопку во время запроса помечаем aria-disabled, сохраняя её в порядке фокуса. Сам атрибут не блокирует нажатия. От повторного входа защищает проверка pending в обработчике. Если оставить только атрибут, повторный запрос продолжит уходить. Поля на время отправки становятся readOnly, чтобы ответ относился к тем значениям, которые человек видит.

Добавьте серверный отказ к тому же пути исправления

Создайте server.mjs. Браузерная проверка не защищает API от прямых запросов. Сервер проверяет типы, ограничения длины и формат, а занятая почта возвращает ошибку поля. Неизвестные имена из ответа клиент не переносит в DOM.

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const files = {
  '/': ['index.html', 'text/html; charset=utf-8'],
  '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
  '/style.css': ['style.css', 'text/css; charset=utf-8']
};
function reply(res, status, value) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(value));
}
createServer(async (req, res) => {
  const path = new URL(req.url, 'http://127.0.0.1:4191').pathname;
  if (req.method === 'GET' && files[path]) {
    const [file, type] = files[path];
    res.writeHead(200, { 'Content-Type': type });
    return res.end(await readFile(new URL(file, import.meta.url)));
  }
  if (req.method !== 'POST' || path !== '/api/consultation') {
    return reply(res, 404, { message: 'Маршрут не найден' });
  }
  if (!req.headers['content-type']?.startsWith('application/json')) {
    return reply(res, 415, { message: 'Ожидается JSON' });
  }
  try {
    const chunks = [];
    let bytes = 0;
    for await (const chunk of req) {
      bytes += chunk.length;
      if (bytes > 4096) return reply(res, 413, { message: 'Большой запрос' });
      chunks.push(chunk);
    }
    const raw = Buffer.concat(chunks).toString('utf8');
    let data;
    try { data = JSON.parse(raw); }
    catch { return reply(res, 400, { message: 'Повреждён JSON' }); }
    const fields = {};
    if (typeof data?.name !== 'string' || data.name.trim().length < 2 ||
        data.name.trim().length > 80) fields.name = 'Введите имя от двух до 80 символов';
    if (typeof data?.email !== 'string' || data.email.length > 200 ||
        !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
      fields.email = 'Проверьте адрес почты';
    } else if (data.email === 'busy@example.test') {
      fields.email = 'Эта почта уже записана. Укажите другую';
    }
    if (Object.keys(fields).length) return reply(res, 422, { fields });
    reply(res, 201, { message: 'Заявка принята' });
  } catch {
    if (!res.writableEnded) reply(res, 500, { message: 'Сбой сервера' });
  }
}).listen(4191, '127.0.0.1', () => {
  console.log('Стенд: http://127.0.0.1:4191');
});

Запустите node server.mjs и откройте http://127.0.0.1:4191. Сервер учебный: он не хранит заявки и не отправляет письма. Тело запроса собирается из байтов и декодируется один раз после Buffer.concat(chunks): граница сетевых фрагментов может пройти посередине кириллической буквы. Проверяем длину исходного тела в байтах, сохраняя лимит 4096.

Формат почты в его регулярном выражении ограничен сценарием; существование ящика здесь не проверяется.

Пройдите исправление целиком клавиатурой

Сначала отправьте пустую форму: перейдите Tab к кнопке и нажмите Enter. Фокус окажется на сводке «Исправьте поля: 2». Рамка должна быть видна. Затем Tab до ссылки имени, Enter, ввод Анна. В описании поля останется подсказка, устранённая ошибка исчезнет.

Перейдите к почте и введите busy@example.test. Отправьте форму. Клиент пропустит этот корректный формат, сервер вернёт 422. В сводке останется одна ссылка. Перейдите по ней, замените почту на reader@example.test и отправьте повторно. Ожидается «Заявка принята», имя остаётся на месте.

При вводе нового адреса серверная ошибка может исчезнуть после локальной проверки формата. Это означает только, что введённое значение ещё не проверялось сервером. Окончательный результат появится при отправке.

Проверка Ожидаемое поведение
Пустая отправка Сводка получает фокус, запрос не уходит
Tab и Enter на ссылке ошибки Фокус переходит в нужное поле
Поле с ошибкой Есть aria-invalid="true" и связанное описание
Исправленное поле Текст ошибки и её идентификатор удалены из описания
Серверный отказ Та же сводка, введённые значения сохранены
Недоступный сервер Общая ошибка, кнопка снова реагирует на нажатия
Увеличение страницы Фокус виден, сообщение не обрезано

Откройте дерево доступности в инструментах браузера. У поля должны быть правильное имя, обязательность и описание. Затем проверьте экранным диктором: после отказа понятно ли число ошибочных полей, читается ли текст при переходе к полю, не повторяется ли сводка несколько раз.

Автоматический аудит найдёт часть нарушений разметки, но сам по себе не оценит понятность текста и удобство последовательности. Такой проход выполняют отдельно; инструменты разобраны в материале о проверке доступности.

Проверьте поломку связи и восстановите её

Для управляемого упражнения временно уберите добавление ${key}-error из aria-describedby. Визуально сообщение останется. Когда фокус находится на поле, из доступного описания исчезнет причина ошибки. Верните связь и сравните результат. Это проверка, которую один скриншот пропустит.

Самостоятельно добавьте поле «Город» с обязательной подписью и серверной проверкой. После пустой отправки сводка должна содержать третью ссылку, а переход по ней — фокусировать новое поле. Во второй задаче пройдите форму при увеличении 200% и без мыши. Запишите, виден ли фокус на сводке, ссылках, полях и кнопке, помещаются ли сообщения. Если обнаружите обрезку или потерю фокуса, сохраните шаг, исправьте его и повторите проверку. Если нарушений нет, отметьте пройденные пункты.

Практику проектирования таких сценариев можно продолжить на курсе по веб-доступности.