Урок 11

Доступность форм

Доступность (a11y) — важная часть формы. Хорошая форма удобна всем: и мышкой, и клавиатурой, и скринридером.

Label и input

Каждое поле должно иметь label:

<label htmlFor="email">Email</label>
<input id="email" type="email" />

Или оборачивать:

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

Клик по label фокусирует поле. Скринридер озвучивает название.

useId для уникальных ID

import { useId } from 'react';

function Input({ label }: { label: string }) {
    const id = useId();

    return (
        <>
            <label htmlFor={id}>{label}</label>
            <input id={id} />
        </>
    );
}

aria-invalid

Если поле с ошибкой — сообщай об этом:

<input
    aria-invalid={!!errors.email}
    aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error">{errors.email}</p>}

Обязательные поля

<label htmlFor="email">
    Email <span aria-hidden="true">*</span>
</label>
<input id="email" required aria-required="true" />

aria-label

Если визуального label нет — используй aria-label:

<input type="search" aria-label="Поиск по сайту" />

Группировка

Связанные поля группируй в fieldset с legend:

<fieldset>
    <legend>Способ оплаты</legend>

    <label>
        <input type="radio" name="payment" value="card" />
        Карта
    </label>

    <label>
        <input type="radio" name="payment" value="cash" />
        Наличные
    </label>
</fieldset>

Порядок табуляции

Поля должны быть в логическом порядке. Избегай tabIndex с положительными значениями.

Фокус

При ошибке фокусируй на первое проблемное поле:

const firstErrorRef = useRef<HTMLInputElement>(null);

useEffect(() => {
    if (Object.keys(errors).length > 0) {
        firstErrorRef.current?.focus();
    }
}, [errors]);

Видимый фокус

Не убирай outline:

/* Плохо */
input:focus { outline: none; }

/* Хорошо */
input:focus-visible { outline: 2px solid var(--accent); }

:focus-visible показывает фокус только при навигации с клавиатуры.

Валидация и озвучивание

<input
    aria-invalid={!!errors.email}
    aria-describedby="email-error"
/>
<p id="email-error" role="alert">
    {errors.email}
</p>

role="alert" заставляет скринридер озвучить сообщение.

Индикатор загрузки

<button disabled={isSubmitting} aria-busy={isSubmitting}>
    {isSubmitting ? 'Отправка...' : 'Отправить'}
</button>

Автозаполнение

<input autoComplete="email" type="email" />
<input autoComplete="current-password" type="password" />
<input autoComplete="name" />

Облегчает заполнение.

Disabled vs readOnly

  • disabled — поле отключено, не отправляется, не фокусируется
  • readOnly — значение видно и отправляется, но менять нельзя

Клавиатура

Проверь, что форма работает без мыши: Tab, Shift+Tab, Enter, Space, стрелки.

Размер области нажатия

Минимум 44×44 px — для мобильных и людей с ограниченными возможностями.

Контраст

Текст и границы должны иметь достаточный контраст (WCAG AA — 4.5:1 для текста).

Итоги

  • Всегда label для полей
  • useId для уникальных ID
  • aria-invalid, aria-describedby для ошибок
  • fieldset для групп
  • Логичный порядок табуляции
  • Видимый фокус
  • role="alert" для сообщений
  • Проверяй на клавиатуре