概要
Zustand Test のフォームでは、Zod が出すエラーメッセージを errors.<field>.message で取り出して表示します。
ここに aria 属性をひと組 足すだけで、スクリーンリーダーが「この入力欄はエラー状態で、メッセージはこれです」と読み上げてくれるようになります。
押さえるのは 3 属性だけです。
| 属性 | 役割 |
|---|---|
aria-invalid | input がエラー状態であることを伝える |
aria-describedby | input と「説明テキスト」(エラー文) を紐付ける |
role="alert" | フォーム全体(root)エラーを「即読み上げ」させる |
インストール
npm install react-hook-form @hookform/resolvers zod
input とエラー文を id で紐付ける
エラー表示用の <p> に 固定の id を付け、<input> の aria-describedby で参照します。aria-invalid はエラーがあるときだけ true にします。
// src/components/SettingsForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { formSchema, type FormData } from "@/schemas/formSchema";
export function SettingsForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(formSchema) });
return (
<form onSubmit={handleSubmit(/* ... */)} noValidate>
<label htmlFor="name">名前</label>
<input
id="name"
type="text"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" className="text-red-500">
{errors.name.message}
</p>
)}
{/* email / rate も同様 */}
</form>
);
}
ポイントは 3 つ。
<label htmlFor="name">と<input id="name">の id を必ず合わせる(基本の a11y)aria-describedbyの値はエラーがあるときのみ"name-error"、ないときはundefinedにして属性ごと外すnoValidateを<form>に付けてブラウザの組み込み検証を切り、Zod の検証だけが UI に出る状態にする
エラーが出る・消えるタイミング
aria-invalid={!!errors.name} は errors.name が undefined から object に変わった瞬間に true になります。スクリーンリーダーは値の変化を拾うので、自動で「無効です」と読み上げ ます。
aria-describedby も同様で、エラー文の id が紐付いた瞬間に「説明テキストはこれ」と関連付きます。<p> の中身が変わったらそのまま新しいメッセージとして読み上げられます。
フォーム全体のエラー(root)
「保存に失敗しました」のような どのフィールドでもないエラー は errors.root に持たせます。表示要素には role="alert" を付けて、表示と同時に 即座に読み上げ させます。
import { useForm } from "react-hook-form";
const {
setError,
formState: { errors },
} = useForm<FormData>(/* ... */);
const onSubmit = async (data: FormData) => {
try {
await saveSettings(data);
} catch (e) {
setError("root", { message: "保存に失敗しました。時間をおいて再度お試しください。" });
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && (
<p role="alert" className="rounded border border-red-500 px-3 py-2 text-red-700">
{errors.root.message}
</p>
)}
{/* ... */}
</form>
);
role="alert" は aria-live="assertive" + aria-atomic="true" と同等です。出現と同時に読み上げが優先されます。
逆に「常時表示しているエラー領域」 には role="alert" を付けてはいけません。読み上げが頻発してしまいます。
共通化したいなら Field コンポーネント
同じ aria の組み合わせを毎回書くと記述量が増えます。1 つだけ Field コンポーネントを切り出すと、a11y と見た目の両方を一括で揃えられます。
// src/components/form/Field.tsx
import type { ReactNode } from "react";
type FieldProps = {
id: string;
label: string;
error?: string;
children: (ariaProps: { id: string; "aria-invalid": boolean; "aria-describedby"?: string }) => ReactNode;
};
export function Field({ id, label, error, children }: FieldProps) {
const errorId = error ? `${id}-error` : undefined;
return (
<div>
<label htmlFor={id}>{label}</label>
{children({
id,
"aria-invalid": !!error,
"aria-describedby": errorId,
})}
{error && <p id={errorId} className="text-red-500">{error}</p>}
</div>
);
}
<Field id="name" label="名前" error={errors.name?.message}>
{(aria) => <input type="text" {...aria} {...register("name")} />}
</Field>
<input> の種類(text / number / select)を変えても aria は同じ形で当たります。
多言語化を考えるなら
エラーメッセージはスキーマに直書きしているため、多言語化が必要なら メッセージキー を返す形に切り替えます。
// src/schemas/formSchema.ts
export const formSchema = z.object({
name: z.string().min(1, "name.required"),
// ...
});
UI 側で t(errors.name.message) のように i18n に通します。スキーマがメッセージのキーを source of truth として持つ 構造は変わりません。
ポイント
<label htmlFor>と<input id>を必ず合わせる。a11y の最初の 1 歩aria-invalidを boolean で渡す。aria-describedbyは エラーがあるときだけ id、なければundefinedで属性を外す- フォーム全体のエラー(root)には
role="alert"を付けて 即読み上げ。常時表示には付けない <form noValidate>でブラウザ検証を切り、Zod の検証だけが UI に反映されるようにする- 同じ aria の組み合わせを繰り返すなら Field コンポーネント で 1 か所に集約する
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zod 編 — エラーメッセージをスキーマに集約する書き方
- RHF 編 —
errorsをformStateから取り出す - TabBar a11y 編 — タブの a11y 対応
- Zustand Test (Build) — 完成例と外部デモ