概要
フォームフィールドにヘルプテキスト(常時表示の説明)とバリデーションエラーメッセージ(エラー時のみ表示)を組み合わせ、aria-describedby で入力欄と関連付けるアクセシブルなフィールドコンポーネントを実装する。エラーとヘルプのどちらが aria-describedby を取るかの制御を含む。
インストール
# 追加インストールは不要
実装
フィールドコンポーネント
// components/FormField.tsx
import { useId } from "react";
type Props = {
label: string;
helpText?: string;
error?: string;
children: (props: {
id: string;
"aria-describedby"?: string;
"aria-invalid"?: boolean;
}) => React.ReactNode;
};
export function FormField({ label, helpText, error, children }: Props) {
const id = useId();
const helpId = helpText ? `${id}-help` : undefined;
const errorId = error ? `${id}-error` : undefined;
// エラーがある場合はエラー ID を優先、なければヘルプ ID
const describedBy = errorId ?? helpId;
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-sm font-medium text-gray-700">
{label}
</label>
{children({
id,
"aria-describedby": describedBy,
"aria-invalid": !!error,
})}
{helpText && !error && (
<p id={helpId} className="text-xs text-gray-500">
{helpText}
</p>
)}
{error && (
<p id={errorId} role="alert" className="text-xs text-red-600">
{error}
</p>
)}
</div>
);
}
使用例: テキスト入力
// components/EmailField.tsx
"use client";
import { useState } from "react";
import { FormField } from "./FormField";
export function EmailField() {
const [value, setValue] = useState("");
const [error, setError] = useState("");
const validate = () => {
if (!value) {
setError("メールアドレスを入力してください");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
setError("正しいメールアドレスを入力してください");
} else {
setError("");
}
};
return (
<FormField
label="メールアドレス"
helpText="登録済みのメールアドレスを入力してください"
error={error}
>
{({ id, ...ariaProps }) => (
<input
id={id}
type="email"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={validate}
className={`rounded border px-3 py-2 text-sm focus:outline-none focus:ring-1 ${
error
? "border-red-400 focus:ring-red-400"
: "border-gray-200 focus:ring-blue-400"
}`}
{...ariaProps}
/>
)}
</FormField>
);
}
セレクトボックスへの適用
// components/CategorySelect.tsx
import { FormField } from "./FormField";
type Props = {
value: string;
onChange: (v: string) => void;
error?: string;
};
export function CategorySelect({ value, onChange, error }: Props) {
return (
<FormField
label="カテゴリ"
helpText="最も近いカテゴリを選択してください"
error={error}
>
{({ id, ...ariaProps }) => (
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-400"
{...ariaProps}
>
<option value="">選択してください</option>
<option value="routing">ルーティング</option>
<option value="form">フォーム</option>
<option value="testing">テスト</option>
</select>
)}
</FormField>
);
}
ポイント
useId()で一意の ID を生成する。Math.random()や連番より安全で、SSR と CSR で ID が一致することが保証されているaria-describedbyにはエラー ID とヘルプ ID のどちらを渡すかを制御する。エラーがあるときはエラーの方を優先し、スクリーンリーダーがエラー内容を読み上げやすくするrole="alert"をエラーメッセージに付けると、動的に表示されたとき aria ライブリージョンとしてスクリーンリーダーに通知されるaria-invalid={!!error}をフィールドに渡すことで、入力欄が無効状態であることを支援技術に伝えられる- render prop パターン(children として関数を渡す)で実装することで、
input・select・textareaのどれにでも同じFormFieldを適用できる helpTextはエラーがないときのみ表示する。エラーとヘルプを同時に表示するとメッセージが混在してユーザーが混乱する