アクセシビリティの向上

前章では、エラー(404エラーを含む)をキャッチしてユーザーにフォールバックを表示する方法を見てきました。しかし、まだパズルのもう一つのピースが残っています:フォームバリデーションです。サーバーアクションを使ったサーバーサイドバリデーションの実装方法と、ReactのuseActionStateフックを使ってフォームエラーを表示する方法を見ていきましょう - アクセシビリティにも配慮しながら!

アクセシビリティとは?

アクセシビビリティとは、障害を持つ人々を含む誰もが利用できるウェブアプリケーションを設計・実装することを指します。キーボードナビゲーション、セマンティックHTML、画像、色、動画など、多くの領域をカバーする広範なトピックです。

このコースではアクセシビリティについて深く掘り下げませんが、Next.jsで利用可能なアクセシビリティ機能と、アプリケーションをよりアクセシブルにするための一般的なプラクティスについて説明します。

アクセシビリティについてさらに学びたい場合は、web.devアクセシビリティ学習コースをお勧めします。

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

Next.jsにはESLint設定にeslint-plugin-jsx-a11yプラグインが含まれており、アクセシビリティ問題を早期に発見するのに役立ちます。例えば、このプラグインはaltテキストのない画像がある場合や、aria-*属性やrole属性を誤って使用している場合に警告を出します。

オプションとして、これを試したい場合は、package.jsonファイルにnext lintスクリプトを追加してください:

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

そしてターミナルでpnpm lintを実行します:

Terminal
pnpm lint

これにより、プロジェクトにESLintをインストールして設定する手順が表示されます。今pnpm lintを実行すると、次のような出力が表示されるはずです:

Terminal
 ESLintの警告やエラーはありません

しかし、altテキストのない画像があった場合どうなるでしょうか?試してみましょう!

/app/ui/invoices/table.tsxに移動し、画像からaltプロパティを削除します。エディタの検索機能を使って<Image>を素早く見つけることができます:

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

再度pnpm lintを実行すると、次の警告が表示されるはずです:

Terminal
./app/ui/invoices/table.tsx
45:25  警告: 画像要素にはaltプロパティが必要です。
意味のあるテキストか、装飾画像の場合は空文字列を指定してください。 jsx-a11y/alt-text

リンターの追加と設定は必須のステップではありませんが、開発プロセスでアクセシビリティ問題を発見するのに役立ちます。

フォームアクセシビリティの向上

私たちのフォームでは、すでにアクセシビリティを向上させるために3つのことを行っています:

  • セマンティックHTML: <div>の代わりにセマンティック要素(<input><option>など)を使用しています。これにより、支援技術(AT)が入力要素にフォーカスを当て、ユーザーに適切な文脈情報を提供できるため、フォームのナビゲーションと理解が容易になります。
  • ラベリング: <label>htmlFor属性を含めることで、各フォームフィールドに説明的なテキストラベルが付きます。これはATサポートを向上させるとともに、ユーザーがラベルをクリックして対応する入力フィールドにフォーカスを当てられるようにすることでユーザビリティも向上させます。
  • フォーカスアウトライン: フィールドはフォーカス時にアウトラインを表示するように適切にスタイル設定されています。これはアクセシビリティにとって重要で、ページ上のアクティブな要素を視覚的に示すことで、キーボードとスクリーンリーダーユーザーの両方がフォーム上の位置を理解するのに役立ちます。tabキーを押すことでこれを確認できます。

これらのプラクティスは、多くのユーザーにとってフォームをよりアクセシブルにするための良い基盤を提供します。しかし、フォームバリデーションエラーには対応していません。

フォームバリデーション

http://localhost:3000/dashboard/invoices/createにアクセスし、空のフォームを送信してください。何が起こりますか?

エラーが発生します!これは空のフォーム値をサーバーアクションに送信しているためです。クライアントまたはサーバーでフォームを検証することでこれを防ぐことができます。

クライアントサイドバリデーション

クライアントでフォームを検証する方法はいくつかあります。最も簡単な方法は、ブラウザが提供するフォームバリデーションを利用することです。フォームの<input><select>要素に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
/>

再度フォームを送信してください。ブラウザは空の値でフォームを送信しようとすると警告を表示します。

このアプローチは一般的に問題ありません。一部のATはブラウザバリデーションをサポートしているためです。

クライアントサイドバリデーションの代替として、サーバーサイドバリデーションがあります。次のセクションでその実装方法を見ていきましょう。今のところ、追加したrequired属性は削除してください。

サーバーサイドバリデーション

サーバーサイドでフォームを検証することで、以下のことが可能になります:

  • データベースに送信する前にデータが期待される形式であることを保証
  • 悪意のあるユーザーがクライアントサイドバリデーションを回避するリスクを低減
  • 有効なデータの基準となる「単一の信頼できる情報源」を保持

create-form.tsxコンポーネントで、reactからuseActionStateフックをインポートします。useActionStateはフックであるため、"use client"ディレクティブを使用してフォームをクライアントコンポーネントにする必要があります:

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

フォームコンポーネント内で、useActionStateフックは:

  • 2つの引数を受け取ります:(action, initialState)
  • 2つの値を返します:[state, formAction] - フォームの状態と、フォーム送信時に呼び出される関数

useActionStateの引数として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という2つの空のキーを持つオブジェクトを作成し、actions.tsファイルからState型をインポートします。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>;
}

最初は混乱するかもしれませんが、サーバーアクションを更新すれば理解できるようになります。さっそく更新しましょう。

action.tsファイルで、Zodを使用してフォームデータを検証できます。FormSchemaを以下のように更新します:

/app/lib/actions.ts
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に強制変換しているため、文字列が空の場合はゼロになります。.gt()関数を使用して、常に0より大きい金額を必要とするようにZodに指示します。
  • status - ステータスフィールドが空の場合、Zodはすでにエラーをスローします("pending"または"paid"を期待しているため)。ユーザーがステータスを選択しなかった場合にも親切なメッセージを追加します。

次に、createInvoiceアクションを更新して、prevStateformDataの2つのパラメータを受け取るようにします:

/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) {
  // ...
}
  • formData - 以前と同じ
  • prevState - useActionStateフックから渡された状態を含みます。この例ではアクション内で使用しませんが、必須のプロパティです

次に、Zodのparse()関数をsafeParse()に変更します:

/app/lib/actions.ts
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ブロック内にこのロジックを配置することなく、より優雅にバリデーションを処理できます。

データベースに情報を送信する前に、条件分岐を使用してフォームフィールドが正しく検証されたかどうかを確認します:

/app/lib/actions.ts
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からのエラーメッセージと共に関数を早期に返します。

ヒント: 空のフォームを送信してvalidatedFieldsをconsole.logし、その形状を確認してください。

最後に、フォームバリデーションをtry/catchブロックの外で個別に処理しているため、データベースエラーに対して特定のメッセージを返すことができます。最終的なコードは次のようになります:

/app/lib/actions.ts
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を使用してエラーにアクセスできます。

各特定のエラーをチェックする三項演算子を追加します。例えば、顧客フィールドの後に以下を追加できます:

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

ヒント: コンポーネント内でstateをconsole.logし、すべてが正しく接続されているか確認できます。フォームがクライアントコンポーネントになったので、Dev Toolsのコンソールを確認してください。

上記のコードでは、以下のariaラベルも追加しています:

  • aria-describedby="customer-error": select要素とエラーメッセージコンテナの関係を確立します。id="customer-error"を持つコンテナがselect要素を説明していることを示します。スクリーンリーダーは、ユーザーがselectボックスを操作したときにこの説明を読み上げ、エラーを通知します。
  • id="customer-error": このid属性は、select入力のエラーメッセージを保持するHTML要素を一意に識別します。aria-describedbyが関係を確立するために必要です。
  • aria-live="polite": スクリーンリーダーは、div内のエラーが更新されたときにユーザーに丁寧に通知する必要があります。コンテンツが変更されると(例えばユーザーがエラーを修正した場合)、スクリーンリーダーはこれらの変更をアナウンスしますが、ユーザーが操作中でないときにのみ行われ、中断しないようにします。

実践: ariaラベルの追加

上記の例を使用して、残りのフォームフィールドにエラーを追加します。また、フィールドが不足している場合、フォームの下部にメッセージを表示する必要があります。UIは次のようになります:

各フィールドのエラーメッセージを表示する請求書作成フォーム

準備ができたら、pnpm lintを実行してariaラベルが正しく使用されているか確認してください。

挑戦したい場合は、この章で学んだ知識を活用して、edit-form.tsxコンポーネントにフォームバリデーションを追加してください。

以下の作業が必要です:

  • edit-form.tsxコンポーネントにuseActionStateを追加
  • updateInvoiceアクションを編集して、Zodからのバリデーションエラーを処理
  • コンポーネントでエラーを表示し、アクセシビリティを向上させるためにariaラベルを追加

準備ができたら、以下のコードスニペットを展開して解決策を確認してください: