コンテンツへスキップ

14

アクセシビリティの向上

前の章では、エラー(404エラーを含む)を捕捉し、ユーザーにフォールバックを表示する方法を見てきました。しかし、まだ議論する必要があるパズルがあります。それはフォームの検証です。サーバーアクションを使ったサーバーサイド検証の実装方法と、ReactのuseActionStateフックを使ってフォームエラーを表示する方法を見ていきましょう。アクセシビリティに配慮しながら!

この章の内容...

ここで取り上げるトピックは次のとおりです。

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

サーバーサイドのフォーム検証を実装する方法。

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

アクセシビリティとは?

アクセシビリティとは、障害のある人を含め、誰もが使えるWebアプリケーションを設計し実装することを指します。キーボードナビゲーション、セマンティック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を実行します。

ターミナル
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キーを押して確認できます。

これらのプラクティスは、多くのユーザーにとってフォームをよりアクセスしやすくするための優れた基盤を築きます。ただし、これらはフォーム検証エラーに対処していません。

フォーム検証

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は、定義するものであれば何でもかまいません。この場合は、2つの空のキー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>;
}

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

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 - 金額の型をstringからnumberに強制変換しているため、文字列が空の場合、デフォルトはゼロになります。.gt()関数を使用して、金額を常に0より大きくする必要があることをZodに伝えましょう。
  • status - Zodは、ステータスフィールドが「pending」または「paid」を期待するため、空の場合には既にエラーをスローします。ユーザーがステータスを選択しない場合にも、分かりやすいメッセージを追加しましょう。

次に、createInvoiceアクションを、2つのパラメータ(prevStateformData)を受け入れるように更新します。

/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をconsole.logで出力し、空のフォームを送信してその形状を確認してください。

最後に、フォームの検証を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をconsole.logで出力し、すべてが正しく接続されているか確認できます。フォームがクライアントコンポーネントになったので、開発ツールでコンソールを確認してください。

上記のコードでは、次の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コンポーネントにフォーム検証を追加してください。

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

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

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

章を完了しました14

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

次へ

15:認証の追加

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