lenec ru

← все посты

Доступные формы: связь label, ошибок и описаний через aria-describedby

17K

Формы — это место, где доступность интерфейса проверяется на прочность. Если у формы нет нормальных лейблов, ошибки появляются в произвольных местах страницы, а описания полей живут отдельно от самих полей — пользователь скринридера никогда не пройдёт регистрацию или оформление заказа. Просто потому что не сможет понять, какой инпут что значит и куда исчезла предыдущая ошибка.

Эта статья — про то, как правильно связать поле, его описание и сообщение об ошибке через нативный HTML и три ARIA-атрибута. Без overengineering, без библиотек, на чистом React. Ответ на десятки баг-репортов, которые я разбирала за два года работы с a11y.

Минимум: label связан с input

Кажется очевидным, но я регулярно вижу формы, где плейсхолдер выполняет роль лейбла. Так нельзя. Плейсхолдер исчезает при вводе, не озвучивается частью скринридеров, и контраст у него обычно ниже минимального.

Правильно — отдельный <label>, связанный через for:

<label for="email">Email</label>
<input id="email" type="email" name="email">

Альтернатива — обёрнутый input внутри label:

<label>
  Email
  <input type="email" name="email">
</label>

Оба варианта работают. Я в дизайн-системе предпочитаю первый, потому что иногда нужно стилизовать label и input по-разному, а с обёрткой это сложнее.

Описание поля

Часто под полем нужна подсказка: «Используется только для уведомлений», «Не менее 8 символов», «Формат: +7 (xxx) xxx-xx-xx». Эта информация должна быть связана с полем, чтобы скринридер озвучил её при фокусе.

Связь — через aria-describedby. Атрибут принимает id элемента, который описывает поле. Можно несколько id через пробел.

<label for="password">Пароль</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
>
<p id="password-hint" class="hint">
  Не менее 8 символов, должна быть хотя бы одна цифра.
</p>

При фокусе на инпут скринридер озвучит: «Пароль, edit, не менее 8 символов, должна быть хотя бы одна цифра».

Важный момент: aria-describedby не обязывает скрывать описание визуально. Подсказка остаётся видимой для всех. Скринридер просто получает её в дополнение к основному имени поля.

Сообщение об ошибке валидации

Самая частая дыра в формах. Пользователь нажал «Отправить» с пустым обязательным полем. Внизу формы или рядом с инпутом появилось «Поле обязательно». Скринридер при возвращении фокуса на инпут это сообщение должен озвучить. Но не озвучит, если связи нет.

Связываем через тот же aria-describedby:

function EmailField({ value, onChange, error }: Props) {
  const id = "email";
  const errorId = "email-error";
  const hintId = "email-hint";

  return (
    <div className="field">
      <label htmlFor={id}>Email</label>
      <input
        id={id}
        type="email"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        aria-describedby={`${hintId} ${error ? errorId : ""}`.trim()}
        aria-invalid={error ? "true" : "false"}
      />
      <p id={hintId} className="hint">
        Используем для уведомлений, спама не будет.
      </p>
      {error && (
        <p id={errorId} className="error" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Что тут важно:

  • aria-describedby ссылается на id описания и id ошибки. Когда ошибки нет — только описание. Когда появляется — оба.
  • aria-invalid="true" сообщает скринридеру, что поле в состоянии ошибки. Без этого ошибка озвучится, но статус «invalid» — нет.
  • role="alert" на сообщение об ошибке заставляет скринридер озвучить ошибку сразу, как она появится в DOM. Без role="alert" пользователь должен будет вернуться фокусом на поле, чтобы услышать ошибку.

aria-required vs required

Если поле обязательное, ставь нативный required:

<input id="email" type="email" required>

Это даёт три эффекта: браузер не даст отправить форму без значения (если submit идёт через нативный механизм), скринридер озвучит «обязательное», и сам HTML-элемент получает соответствующий валидационный псевдокласс :invalid.

Использовать aria-required="true" вместо required почти никогда не нужно. aria-required сообщает скринридеру информацию, но не подключает реальную валидацию. Делает интерфейс «доступным на словах», но не на деле.

Случай, когда aria-required оправдан: нужно дать пользователю возможность отправить форму с пустым полем (черновик), но при этом сообщить, что для финальной отправки поле обязательно. Тогда нативный required не подходит, а aria-required даёт корректное сообщение скринридеру.

Группировка полей: fieldset и legend

Если в форме есть набор полей под одним смыслом (адрес, банковская карта, контактные данные) — оборачивай их в <fieldset> с <legend>. Это даёт скринридеру понять, что эти поля связаны.

<fieldset>
  <legend>Адрес доставки</legend>
  <label for="city">Город</label>
  <input id="city" name="city">
  <label for="street">Улица</label>
  <input id="street" name="street">
</fieldset>

Скринридер при фокусе на «Город» озвучит «Адрес доставки, город edit». Контекст становится понятен.

Особенно важно для радиогрупп и чекбокс-групп:

<fieldset>
  <legend>Способ оплаты</legend>
  <label>
    <input type="radio" name="payment" value="card">
    Картой
  </label>
  <label>
    <input type="radio" name="payment" value="cash">
    Наличными
  </label>
</fieldset>

Без legend скринридер озвучит каждый радио как отдельный, без понимания, что это варианты одной группы.

Стилизация fieldset обычно проблемная — у него по умолчанию рамка, легенда вырезает её сверху. Можно убрать через border: none, но не отказываться от тегов как таковых.

Резюме всех ошибок наверху формы

На больших формах после неудачной отправки удобно показывать общую сводку всех ошибок наверху. Это два преимущества: пользователь сразу видит, сколько проблем, и при клике на ошибку фокус прыгает на соответствующее поле.

function ErrorSummary({ errors }: { errors: Record<string, string> }) {
  const summaryRef = useRef<HTMLDivElement>(null);
  const list = Object.entries(errors);

  useEffect(() => {
    if (list.length > 0) {
      summaryRef.current?.focus();
    }
  }, [list.length]);

  if (list.length === 0) return null;

  return (
    <div
      ref={summaryRef}
      tabIndex={-1}
      role="alert"
      aria-labelledby="error-summary-title"
    >
      <h2 id="error-summary-title">Найдено ошибок: {list.length}</h2>
      <ul>
        {list.map(([fieldId, message]) => (
          <li key={fieldId}>
            <a href={`#${fieldId}`}>{message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

После submit фокусируем сам summary (он принимает фокус через tabIndex={-1}), скринридер озвучивает заголовок и количество ошибок. Каждая ошибка — ссылка на якорь поля.

На уровне field тоже оставляем сообщения и aria-describedby — общая сводка не отменяет локальные подсказки.

Что не делать

Не используй placeholder вместо label

Самая частая ошибка. «Минимализм» интерфейса важен меньше, чем доступность. Если макет жёстко требует «лейбл внутри инпута», смотри в сторону float-label паттерна (лейбл уезжает наверх при фокусе или вводе) — там лейбл существует физически, просто визуально совмещён с плейсхолдером.

Не пиши длинные тексты в aria-label

aria-label заменяет содержимое для скринридера. Если описание поля в трёх предложениях — это не имя элемента, это описание, и оно идёт в aria-describedby. У aria-label цель — короткое имя, желательно 2-5 слов.

Не вставляй сообщение об ошибке в title

Атрибут title показывается как тултип на hover. На клавиатурном фокусе обычно не появляется. Скринридеры по-разному его озвучивают. Не надёжный способ доносить ошибки.

Не очищай aria-describedby при исчезновении ошибки

Если описание остаётся (только ошибка пропадает), оставляй id описания в aria-describedby. Иначе атрибут пустой → скринридер не знает про подсказку.

Как проверять

Беру форму, прохожу через VoiceOver или NVDA, на каждом инпуте слушаю:

  1. Озвучивается ли лейбл? Если нет — связь сломана.
  2. Озвучивается ли подсказка? Если нет — нет aria-describedby или id не совпадают.
  3. После submit с ошибкой, при фокусе на проблемное поле, озвучивается ли ошибка? Если нет — нет связи через aria-describedby.
  4. Озвучивается ли «invalid» / «недопустимое»? Если нет — нет aria-invalid.

Все четыре «да» — форма доступна. Любой «нет» — баг, исправлять.

Для автотестов axe-core ловит большинство проблем: missing label, mismatched id в for/aria-describedby, aria-invalid без сообщения. Подключи axe в Playwright и прогоняй по формам в CI.

Что унести с собой

Минимум для каждого поля: label for="..." на видимый текст лейбла, aria-describedby на id подсказки и id текущей ошибки, aria-invalid при ошибке, required для обязательных. Группы полей — в fieldset с legend. На больших формах — сводка ошибок наверху с фокусом на неё после submit.

Эти пять вещей закрывают 95% доступности форм. Без библиотек, без компонентов, без зависимостей. Чистый HTML и три ARIA-атрибута.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.