コンテンツにスキップ

Chapter 14: アクセシビリティ

エラー処理が整ったら,誰にとっても使いやすい UI へ仕上げる番です。ここではフォームバリデーションを例に,サーバーサイド検証と useActionState を組み合わせて,アクセシビリティを考慮したエラーメッセージを表示します。


この章で扱う内容

  • Next.js で eslint-plugin-jsx-a11y を使い,アクセシビリティのベストプラクティスを適用する方法
  • サーバーサイドのフォームバリデーションの実装方法
  • useActionState でフォームエラーを扱い,ユーザーに表示する方法

アクセシビリティとは?

アクセシビリティとは,すべての人が利用できるように Web アプリケーションを設計・実装することです。キーボード操作,セマンティック HTML,画像・色・動画など多岐にわたります。 本コースでは詳細までは扱いませんが,Next.js が提供する機能と一般的な実践方法をいくつか紹介します。 より深く学ぶなら,web.dev の Learn Accessibility をおすすめします。


Next.js の ESLint アクセシビリティプラグイン

Next.js の ESLint 設定には eslint-plugin-jsx-a11y が含まれており,アクセシビリティの問題を早期に検出できます。alt のない画像や,不適切な aria-*role の使用などを警告します。

試したい場合は,package.jsonnext lint のスクリプトを追加します。

/package.json
"scripts": {
  "build": "next build",
  "dev": "next dev",
  "start": "next start",
  "lint": "next lint"
}

ターミナルで実行します。

Terminal
pnpm lint

初回はセットアップに誘導されます。今の状態で実行すると,次のように表示されるはずです。

Terminal
✔ No ESLint warnings or errors

では,alt のない画像があったらどうなるでしょう?alt を削除して再度 pnpm lint を実行してみます。

/app/ui/invoices/table.tsx
<Image
  src={invoice.image_url}
  className="rounded-full"
  width={28}
  height={28}
  alt={`${invoice.name}'s profile picture`} // この行を削除
/>
Terminal
./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

リンターは必須ではありませんが,開発中にアクセシビリティの問題を早期発見するのに役立ちます。


フォームのアクセシビリティを向上させる

すでに以下の 3 点を実施しています。

  1. セマンティック HTML<input><option> など,意味のある要素を使用。支援技術(AT)が入力要素に集中でき,文脈情報を提供できます。
  2. ラベリング<label>htmlFor で各フィールドに説明テキストを関連付け。AT のサポートが向上し,ラベルクリックで該当フィールドにフォーカスできます。
  3. フォーカスアウトライン:フォーカス中の視覚的なアウトラインを適切に表示。キーボード・スクリーンリーダー両ユーザーにとって現在位置が分かります(Tab で確認しましょう)。

ただし,これらはバリデーションとエラー表示をカバーしていません。


フォームバリデーション

http://localhost:3000/dashboard/invoices/create で空のフォームを送信してみるとエラーになります。 空の値が Server Action に送られているためです。これを防ぐにはクライアントまたはサーバーで検証します。

クライアントサイド検証

いちばん手軽なのは,ブラウザのネイティブ検証を利用する方法です。required を各要素に追加します。

/app/ui/invoices/create-form.tsx
<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 はいったん削除しておきます。


サーバーサイド検証

サーバー側でフォームを検証する利点:

  • DB に送る前に期待する形式であることを保証
  • クライアント検証の回避(悪意ある操作)に対する耐性
  • 何が「正しいデータ」かの単一の真実の所在(Single Source of Truth)

create-form.tsxuseActionState を使います。フックなので "use client" が必要です。

/app/ui/invoices/create-form.tsx
'use client';

// ...
import { useActionState } from 'react';

useActionState(action, initialState) を受け取り,[state, formAction] を返します。 createInvoice アクションを渡し,<form action={formAction}> を指定します。

/app/ui/invoices/create-form.tsx
// ...
import { useActionState } from 'react';

export default function Form({ customers }: { customers: CustomerField[] }) {
  const [state, formAction] = useActionState(createInvoice, initialState);

  return <form action={formAction}>...</form>;
}

initialState は自由に定義できます。ここでは messageerrors を持つオブジェクトを使い,型は actions.ts から State をインポートします(次に作成)。

/app/ui/invoices/create-form.tsx
// ...
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>;
}

次にサーバーアクションを更新します。検証には Zod を使います。

/app/lib/actions.ts
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: 'Please select a customer.',
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: 'Please enter an amount greater than $0.' }),
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: 'Please select an invoice status.',
  }),
  date: z.string(),
});
  • customerId:空だと型エラーになりますが,わかりやすいメッセージを追加
  • amount:文字列→数値の coerce を使うと空文字は 0 になりがち。0 より大きいことを gt(0) で強制
  • status'pending' | 'paid' の列挙。未選択時のメッセージを追加

createInvoice(prevState, formData) で受け取る形に変更します。

/app/lib/actions.ts
export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};

export async function createInvoice(prevState: State, formData: FormData) {
  // ...
}

検証は parse ではなく safeParse を使い,成功/失敗を分岐します。

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  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: 'Missing Fields. Failed to Create Invoice.',
    };
  }

  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 {
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

フォームにエラーを表示する

create-form.tsx で,state.errors を参照してフィールドごとにエラーを表示します。 また,ARIA 属性を追加し,支援技術で適切に読み上げられるようにします。

/app/ui/invoices/create-form.tsx
<form action={formAction}>
  <div className="rounded-md bg-gray-50 p-4 md:p-6">
    {/* Customer Name */}
    <div className="mb-4">
      <label htmlFor="customer" className="mb-2 block text-sm font-medium">
        Choose customer
      </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>
            Select a customer
          </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>
  • aria-describedby="customer-error"select とエラー要素の関連付け
  • id="customer-error":エラー要素の一意な識別子
  • aria-live="polite":エラー文言の更新を割り込み無しで読み上げ

ヒント:console.log(state) で,エラーが正しく届いているか DevTools で確認しましょう(このフォームはクライアントコンポーネントです)。


Practice:ARIA ラベルの追加

上記の例にならい,残りのフォームフィールドにもエラー表示と ARIA 属性を追加してください。 また,いずれかのフィールドが欠けている場合は,フォーム下部に共通のエラーメッセージを表示しましょう。

alt text

仕上がったら pnpm lint を実行して ARIA の使い方に問題がないかを確認してください。

さらに挑戦したい場合は,この章で学んだ内容を edit-form.tsx にも適用してください。

  • useActionStateedit-form.tsx に追加
  • updateInvoice アクションを Zod の検証エラーに対応
  • コンポーネント側でエラーを表示し,ARIA ラベルも付与
/app/ui/invoices/edit-form.tsx
// ...
import { updateInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';

export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  const initialState: State = { message: null, errors: {} };
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
  const [state, formAction] = useActionState(updateInvoiceWithId, initialState);

  return <form action={formAction}>{/* ... */}</form>;
}
/app/lib/actions.ts
export async function updateInvoice(
  id: string,
  prevState: State,
  formData: FormData,
) {
  const validatedFields = UpdateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Update Invoice.',
    };
  }

  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;

  try {
    await sql`
      UPDATE invoices
      SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
      WHERE id = ${id}
    `;
  } catch (error) {
    return { message: 'Database Error: Failed to Update Invoice.' };
  }

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

これで,アクセシビリティに配慮したサーバーサイド検証と,useActionState を用いたエラー表示が実装できました。次章へ進みましょう。