Повышение доступности
В предыдущей главе мы рассмотрели, как перехватывать ошибки (включая 404) и показывать пользователю запасной вариант. Однако осталась ещё одна важная часть: валидация форм. Давайте разберём, как реализовать валидацию на стороне сервера с помощью Server Actions и как отображать ошибки формы с использованием хука React useActionState
— не забывая о доступности!
Что такое доступность?
Доступность (accessibility) означает проектирование и реализацию веб-приложений, которыми могут пользоваться все, включая людей с ограниченными возможностями. Это обширная тема, охватывающая множество аспектов, таких как навигация с клавиатуры, семантический HTML, изображения, цвета, видео и т.д.
Хотя мы не будем углубляться в доступность в этом курсе, мы обсудим доступные функции в Next.js и некоторые распространённые практики для повышения доступности ваших приложений.
Если вы хотите узнать больше о доступности, рекомендуем курс Learn Accessibility от web.dev.
Использование ESLint-плагина для доступности в Next.js
Next.js включает плагин eslint-plugin-jsx-a11y
в свою конфигурацию ESLint, чтобы помочь выявлять проблемы с доступностью на ранних этапах. Например, этот плагин предупреждает, если у вас есть изображения без alt
-текста, неправильно используются атрибуты aria-*
и role
, и многое другое.
Если вы хотите попробовать это, добавьте next lint
как скрипт в файл package.json
:
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"lint": "next lint"
},
Затем выполните pnpm lint
в терминале:
pnpm lint
Это поможет вам установить и настроить ESLint для вашего проекта. Если вы запустите pnpm lint
сейчас, вы должны увидеть следующий вывод:
✔ No ESLint warnings or errors
Но что произойдёт, если у вас есть изображение без alt
-текста? Давайте проверим!
Перейдите в /app/ui/invoices/table.tsx
и удалите проп alt
у изображения. Вы можете использовать поиск в редакторе, чтобы быстро найти <Image>
:
<Image
src={invoice.image_url}
className="rounded-full"
width={28}
height={28}
alt={`${invoice.name}'s profile picture`} // Удалите эту строку
/>
Теперь снова выполните pnpm lint
, и вы увидите следующее предупреждение:
./app/ui/invoices/table.tsx
45:25 Warning: Image elements must have an alt prop,
either with meaningful text, or an empty string for decorative images. jsx-a11y/alt-text
Хотя добавление и настройка линтера не является обязательным шагом, это может помочь выявлять проблемы с доступностью в процессе разработки.
Улучшение доступности форм
Мы уже делаем три вещи для улучшения доступности наших форм:
- Семантический HTML: Использование семантических элементов (
<input>
,<option>
и т.д.) вместо<div>
. Это позволяет вспомогательным технологиям (AT) фокусироваться на элементах ввода и предоставлять пользователю соответствующую контекстную информацию, делая форму более удобной для навигации и понимания. - Подписи: Включение
<label>
и атрибутаhtmlFor
гарантирует, что каждое поле формы имеет описательную текстовую метку. Это улучшает поддержку AT, предоставляя контекст, а также повышает удобство использования, позволяя пользователям кликать по метке для фокусировки на соответствующем поле ввода. - Контур фокуса: Поля правильно стилизованы, чтобы показывать контур при фокусировке. Это критически важно для доступности, так как визуально указывает на активный элемент на странице, помогая пользователям клавиатуры и экранных читателей понимать, где они находятся в форме. Вы можете проверить это, нажав
tab
.
Эти практики закладывают хорошую основу для повышения доступности ваших форм для многих пользователей. Однако они не охватывают валидацию форм и ошибки.
Валидация форм
Перейдите по адресу http://localhost:3000/dashboard/invoices/create и отправьте пустую форму. Что произойдёт?
Вы получите ошибку! Это происходит потому, что вы отправляете пустые значения формы в ваше Server Action. Вы можете предотвратить это, выполнив валидацию формы на клиенте или сервере.
Валидация на стороне клиента
Есть несколько способов выполнить валидацию форм на клиенте. Самый простой — использовать встроенную валидацию браузера, добавив атрибут required
к элементам <input>
и <select>
в ваших формах. Например:
<input
id="amount"
name="amount"
type="number"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
required
/>
Снова отправьте форму. Браузер покажет предупреждение, если вы попытаетесь отправить форму с пустыми значениями.
Этот подход в целом приемлем, так как некоторые вспомогательные технологии поддерживают валидацию браузера.
Альтернативой клиентской валидации является валидация на стороне сервера. Давайте посмотрим, как её реализовать в следующем разделе. А пока удалите атрибуты required
, если вы их добавили.
Валидация на стороне сервера (Server-Side validation)
Проверяя формы на сервере, вы можете:
- Убедиться, что данные соответствуют ожидаемому формату перед отправкой в базу данных.
- Снизить риск обхода клиентской валидации злоумышленниками.
- Иметь единый источник истины для определения валидных данных.
В вашем компоненте create-form.tsx
импортируйте хук useActionState
из react
. Поскольку useActionState
— это хук, вам нужно преобразовать форму в клиентский компонент с помощью директивы "use client"
:
'use client';
// ...
import { useActionState } from 'react';
Внутри вашего компонента Form хук useActionState
:
- Принимает два аргумента:
(action, initialState)
. - Возвращает два значения:
[state, formAction]
— состояние формы и функцию, которая вызывается при отправке формы.
Передайте ваше действие createInvoice
в качестве аргумента useActionState
, а внутри атрибута <form action={}>
вызовите formAction
.
// ...
import { useActionState } from 'react';
export default function Form({ customers }: { customers: CustomerField[] }) {
const [state, formAction] = useActionState(createInvoice, initialState);
return <form action={formAction}>...</form>;
}
initialState
может быть любым объектом, который вы определите. В данном случае создайте объект с двумя пустыми ключами: message
и errors
, а также импортируйте тип State
из файла actions.ts
. State
пока не существует, но мы создадим его далее:
// ...
import { createInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState: State = { message: null, errors: {} };
const [state, formAction] = useActionState(createInvoice, initialState);
return <form action={formAction}>...</form>;
}
Это может показаться сложным на первый взгляд, но станет понятнее после обновления серверного действия. Давайте сделаем это сейчас.
В файле action.ts
вы можете использовать Zod для валидации данных формы. Обновите FormSchema
следующим образом:
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Пожалуйста, выберите клиента.',
}),
amount: z.coerce
.number()
.gt(0, { message: 'Пожалуйста, введите сумму больше $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Пожалуйста, выберите статус счёта.',
}),
date: z.string(),
});
customerId
— Zod уже выбрасывает ошибку, если поле клиента пустое, так как ожидает типstring
. Но давайте добавим дружественное сообщение, если пользователь не выберет клиента.amount
— Поскольку вы преобразуете тип суммы изstring
вnumber
, она по умолчанию будет равна нулю, если строка пустая. Давайте укажем Zod, что сумма всегда должна быть больше 0, с помощью функции.gt()
.status
— Zod уже выбрасывает ошибку, если поле статуса пустое, так как ожидает значения "pending" или "paid". Давайте также добавим дружественное сообщение, если пользователь не выберет статус.
Далее обновите ваше действие createInvoice
, чтобы оно принимало два параметра — prevState
и formData
:
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {
// ...
}
formData
— то же, что и раньше.prevState
— содержит состояние, переданное из хукаuseActionState
. В этом примере вы не будете использовать его в действии, но это обязательный параметр.
Затем замените функцию Zod parse()
на safeParse()
:
export async function createInvoice(prevState: State, formData: FormData) {
// Проверка полей формы с помощью Zod
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// ...
}
safeParse()
вернёт объект, содержащий либо поле success
, либо error
. Это поможет обрабатывать валидацию более аккуратно, без необходимости помещать эту логику в блок try/catch
.
Перед отправкой информации в базу данных проверьте, были ли поля формы успешно валидированы, с помощью условия:
export async function createInvoice(prevState: State, formData: FormData) {
// Проверка полей формы с помощью Zod
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// Если валидация формы не прошла, верните ошибки. Иначе продолжайте.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Не заполнены обязательные поля. Не удалось создать счёт.',
};
}
// ...
}
Если validatedFields
не успешен, мы возвращаем функцию с сообщениями об ошибках от Zod.
Совет: console.log
validatedFields
и отправьте пустую форму, чтобы увидеть её структуру.
Наконец, поскольку вы обрабатываете валидацию формы отдельно, вне блока try/catch, вы можете вернуть конкретное сообщение для любых ошибок базы данных. Ваш финальный код должен выглядеть так:
export async function createInvoice(prevState: State, formData: FormData) {
// Проверка формы с помощью Zod
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// Если валидация формы не прошла, верните ошибки. Иначе продолжайте.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Не заполнены обязательные поля. Не удалось создать счёт.',
};
}
// Подготовка данных для вставки в базу данных
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// Вставка данных в базу данных
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
// Если произошла ошибка базы данных, верните более конкретное сообщение.
return {
message: 'Ошибка базы данных: Не удалось создать счёт.',
};
}
// Ревалидация кэша страницы счетов и перенаправление пользователя.
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
Отлично, теперь давайте отобразим ошибки в вашем компоненте формы. Вернувшись в компонент create-form.tsx
, вы можете получить доступ к ошибкам через состояние формы state
.
Добавьте тернарный оператор, который проверяет каждую конкретную ошибку. Например, после поля клиента вы можете добавить:
<form action={formAction}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Имя клиента */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Выберите клиента
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
aria-describedby="customer-error"
>
<option value="" disabled>
Выберите клиента
</option>
{customers.map((name) => (
<option key={name.id} value={name.id}>
{name.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
// ...
</div>
</form>
Совет: Вы можете console.log
state
внутри вашего компонента и проверить, всё ли правильно подключено. Проверьте консоль в Dev Tools, так как ваша форма теперь является клиентским компонентом.
В коде выше вы также добавляете следующие ARIA-метки:
aria-describedby="customer-error"
: Устанавливает связь между элементомselect
и контейнером с сообщением об ошибке. Указывает, что контейнер сid="customer-error"
описывает элементselect
. Программы чтения с экрана будут озвучивать это описание при взаимодействии пользователя сselect
, чтобы уведомить его об ошибках.id="customer-error"
: Этот атрибутid
уникально идентифицирует HTML-элемент, содержащий сообщение об ошибке для вводаselect
. Это необходимо для установления связи сaria-describedby
.aria-live="polite"
: Программа чтения с экрана должна вежливо уведомлять пользователя при обновлении ошибки внутриdiv
. Когда содержимое изменяется (например, когда пользователь исправляет ошибку), программа чтения с экрана объявит эти изменения, но только когда пользователь бездействует, чтобы не прерывать его.
Практика: Добавление ARIA-меток
Используя пример выше, добавьте ошибки к остальным полям формы. Вы также должны показать сообщение внизу формы, если какие-либо поля не заполнены. Ваш интерфейс должен выглядеть так:

Когда будете готовы, запустите pnpm lint
, чтобы проверить, правильно ли вы используете ARIA-метки.
Если хотите бросить себе вызов, примените знания, полученные в этой главе, и добавьте валидацию формы в компонент edit-form.tsx
.
Вам нужно:
- Добавить
useActionState
в ваш компонентedit-form.tsx
. - Изменить действие
updateInvoice
для обработки ошибок валидации от Zod. - Отобразить ошибки в вашем компоненте и добавить ARIA-метки для улучшения доступности.
Когда будете готовы, разверните сниппет ниже, чтобы увидеть решение: