コンテンツにスキップ

14

アクセシビリティの向上

前の章では、エラー(404エラーを含む)をキャッチしてユーザーにフォールバックを表示する方法を説明しました。しかし、パズルのもう一つのピース、フォームのバリデーションについても議論する必要があります。Server Actions を使用したサーバーサイドバリデーションの実装方法と、React の useActionState フックを使用してフォームのエラーを表示する方法を見てみましょう。アクセシビリティも考慮します!

このチャプターでは...

取り上げるトピックは以下の通りです。

Next.js で eslint-plugin-jsx-a11y を使用して、アクセシビリティのベストプラクティスを実装する方法。

サーバーサイドのフォームバリデーションを実装する方法。

React の useActionState フックを使用してフォームのエラーを処理し、ユーザーに表示する方法。

アクセシビリティとは?

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

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

アクセシビリティについてさらに詳しく知りたい場合は、web.dev による Learn Accessibility コースを推奨します。 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 を実行します。

ターミナル
pnpm lint

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

ターミナル
 No ESLint warnings or errors

しかし、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`} // Delete this line
/>

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

ターミナル
./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 つのことを行っています。

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

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

フォームのバリデーション

https://:3000/dashboard/invoices/create に移動し、空のフォームを送信してください。どうなりますか?

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

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

クライアントでフォームをバリデーションするには、いくつかの方法があります。最も簡単なのは、フォームの <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] - フォームの状態、およびフォームが送信されたときに呼び出される関数。

createInvoice アクションを useActionState の引数として渡し、<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: '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 - 顧客フィールドが空の場合、Zod はすでにエラーをスローします。なぜなら、string 型を期待しているからです。しかし、ユーザーが顧客を選択しなかった場合、フレンドリーなメッセージを追加しましょう。
  • amount - amount の型を string から number に変換しているため、文字列が空の場合はデフォルトでゼロになります。.gt() 関数を使用して、amount が常に 0 より大きいことを Zod に伝えます。
  • status - "pending" または "paid" を期待しているため、ステータスフィールドが空の場合、Zod はすでにエラーをスローします。ユーザーがステータスを選択しなかった場合も、フレンドリーなメッセージを追加しましょう。

次に、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) {
  // Validate form fields using 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) {
  // Validate form fields using Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // If form validation fails, return errors early. Otherwise, continue.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  // ...
}

validatedFields が成功しなかった場合、Zod のエラーメッセージとともに早期に関数を返します。

ヒント: validatedFields をコンソールログに記録し、空のフォームを送信してその形状を確認してください。

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

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Validate form using Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // If form validation fails, return errors early. Otherwise, continue.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  // Prepare data for insertion into the database
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  // Insert data into the database
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    // If a database error occurs, return a more specific error.
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }
 
  // Revalidate the cache for the invoices page and redirect the user.
  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">
    {/* 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>

ヒント: コンポーネント内で state をコンソールログに記録して、すべてが正しく接続されているか確認できます。フォームはクライアントコンポーネントになったため、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 は次のようになります。

Create invoice form showing error messages for each field.

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

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

次のことを行う必要があります。

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

準備ができたら、以下のコードスニペットを展開してソリューションを表示してください。

チャプターを完了しました。14

素晴らしい、React Form Status とサーバーサイドバリデーションでフォームのアクセシビリティを向上させる方法を学びました。

次へ

15: 認証の追加

アプリケーションはほぼ準備が整いました。次の章では、NextAuth.js を使用してアプリケーションに認証を追加する方法を学びます。

App Router: アクセシビリティの向上 | Next.js