概要
フォームの各フィールド直下にエラーメッセージを表示し、aria-describedby でスクリーンリーダーに紐付ける。クライアントバリデーション(フォーカスアウト時)と API エラー(サブミット後)の両方に対応したパターンを示す。
インストール
# 追加インストールは不要
実装
フィールドエラーコンポーネント
// components/FieldError.tsx
type Props = {
id: string;
message?: string;
};
export default function FieldError({ id, message }: Props) {
if (!message) return null;
return (
<p id={id} role="alert" className="mt-1 text-xs text-red-600">
{message}
</p>
);
}
フォームフィールドラッパー
// components/FormField.tsx
import type { ReactNode } from "react";
import FieldError from "./FieldError";
type Props = {
label: string;
htmlFor: string;
error?: string;
children: ReactNode;
};
export default function FormField({ label, htmlFor, error, children }: Props) {
const errorId = `${htmlFor}-error`;
return (
<div className="space-y-1">
<label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700">
{label}
</label>
{/* children に aria-describedby を渡すため cloneElement は使わず、呼び出し側で設定する */}
{children}
<FieldError id={errorId} message={error} />
</div>
);
}
インラインエラー付きフォーム
// components/SignUpForm.tsx
"use client";
import { useState } from "react";
import FormField from "./FormField";
import FieldError from "./FieldError";
type FormErrors = {
email?: string;
password?: string;
confirmPassword?: string;
form?: string;
};
function validateEmail(value: string): string | undefined {
if (!value) return "メールアドレスを入力してください";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "正しいメールアドレスを入力してください";
}
function validatePassword(value: string): string | undefined {
if (!value) return "パスワードを入力してください";
if (value.length < 8) return "パスワードは 8 文字以上で入力してください";
}
export default function SignUpForm() {
const [values, setValues] = useState({ email: "", password: "", confirmPassword: "" });
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(false);
function handleBlur(field: string) {
setTouched((prev) => ({ ...prev, [field]: true }));
// フォーカスアウト時にバリデーション
const newErrors = { ...errors };
if (field === "email") {
newErrors.email = validateEmail(values.email);
}
if (field === "password") {
newErrors.password = validatePassword(values.password);
}
if (field === "confirmPassword") {
newErrors.confirmPassword =
values.password !== values.confirmPassword ? "パスワードが一致しません" : undefined;
}
setErrors(newErrors);
}
function handleChange(field: string, value: string) {
setValues((prev) => ({ ...prev, [field]: value }));
// 入力中はエラーをクリア
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const newErrors: FormErrors = {
email: validateEmail(values.email),
password: validatePassword(values.password),
confirmPassword:
values.password !== values.confirmPassword ? "パスワードが一致しません" : undefined,
};
if (Object.values(newErrors).some(Boolean)) {
setErrors(newErrors);
setTouched({ email: true, password: true, confirmPassword: true });
return;
}
setLoading(true);
try {
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: values.email, password: values.password }),
});
if (!res.ok) {
const data = await res.json() as { error?: string; field?: string };
if (data.field) {
setErrors({ [data.field]: data.error });
} else {
setErrors({ form: data.error ?? "登録に失敗しました" });
}
}
} catch {
setErrors({ form: "通信エラーが発生しました" });
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="w-80 space-y-4 rounded-lg border p-8" noValidate>
<h1 className="text-xl font-bold">アカウント登録</h1>
{/* フォーム全体のエラー */}
{errors.form && (
<p role="alert" className="rounded bg-red-50 px-3 py-2 text-sm text-red-600">
{errors.form}
</p>
)}
<FormField label="メールアドレス" htmlFor="email" error={touched.email ? errors.email : undefined}>
<input
id="email"
type="email"
value={values.email}
onChange={(e) => handleChange("email", e.target.value)}
onBlur={() => handleBlur("email")}
aria-describedby={errors.email && touched.email ? "email-error" : undefined}
aria-invalid={!!(errors.email && touched.email)}
className={`block w-full rounded border px-3 py-2 text-sm ${
errors.email && touched.email ? "border-red-500 focus:ring-red-200" : "border-gray-300"
} focus:outline-none focus:ring-2`}
/>
</FormField>
<FormField label="パスワード" htmlFor="password" error={touched.password ? errors.password : undefined}>
<input
id="password"
type="password"
value={values.password}
onChange={(e) => handleChange("password", e.target.value)}
onBlur={() => handleBlur("password")}
aria-describedby={errors.password && touched.password ? "password-error" : undefined}
aria-invalid={!!(errors.password && touched.password)}
className={`block w-full rounded border px-3 py-2 text-sm ${
errors.password && touched.password ? "border-red-500" : "border-gray-300"
} focus:outline-none focus:ring-2`}
/>
</FormField>
<FormField label="パスワード(確認)" htmlFor="confirmPassword" error={touched.confirmPassword ? errors.confirmPassword : undefined}>
<input
id="confirmPassword"
type="password"
value={values.confirmPassword}
onChange={(e) => handleChange("confirmPassword", e.target.value)}
onBlur={() => handleBlur("confirmPassword")}
aria-describedby={errors.confirmPassword && touched.confirmPassword ? "confirmPassword-error" : undefined}
aria-invalid={!!(errors.confirmPassword && touched.confirmPassword)}
className={`block w-full rounded border px-3 py-2 text-sm ${
errors.confirmPassword && touched.confirmPassword ? "border-red-500" : "border-gray-300"
} focus:outline-none focus:ring-2`}
/>
</FormField>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-blue-600 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "登録中…" : "登録する"}
</button>
</form>
);
}
ポイント
aria-describedbyでフィールドとエラーメッセージを紐付ける。スクリーンリーダーはフィールドにフォーカスしたときにエラーメッセージを読み上げる。エラー ID は${fieldId}-errorのように一意なパターンで生成するaria-invalid={true}をエラー時のフィールドに付けることで、スクリーンリーダーが「このフィールドに問題がある」と認識できるtouched状態を持ち、フォーカスアウトまたはサブミット後にのみエラーを表示する。初期表示でエラーを出さないことでユーザーへのプレッシャーを減らす- 入力中(
onChange)にエラーをクリアし、フォーカスアウト(onBlur)で再バリデーションすることで、修正中のストレスを軽減しながら確認タイミングを保てる - API エラーはフィールド単位(
fieldプロパティ)とフォーム全体(form)を使い分ける。例えばメールアドレスが重複登録の場合はfield: "email"でフィールド近傍に表示し、サーバーエラーはフォーム上部に表示する