🙆‍♂️

ゼロから学ぶ React, Next.js㉑【Learn Next.js】Chapter14

2024/05/25に公開

第14章 アクセシビリティの向上

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

この章で扱うトピック

  • 🙆‍♂️ Next.jsでeslint-plugin-jsx-a11yを使用してアクセシビリティのベストプラクティスを実装する方法。
  • 📄 サーバーサイドのフォームバリデーションを実装する方法。
  • ❄️ React useFormStateフックを使用してフォームのエラーを処理し、ユーザーに表示する方法。

アクセシビリティとは?

アクセシビリティとは、障がいのある人を含むすべての人が使用できるようにWebアプリケーションを設計・実装することを指します。キーボードナビゲーション、セマンティックHTML、画像、色、動画など、多くの分野をカバーする広大なトピックです。

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

アクセシビリティについてもっと知りたい方は、web.devLearn Accessibilityコースをお勧めします。


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

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

これがどのように機能するかを見てみましょう!

package.jsonファイルのスクリプトにnext lintを追加します。

/package.json
 "scripts": {
     "build": "next build",
     "dev": "next dev",
     "seed": "node -r dotenv/config ./scripts/seed.js",  
     "start": "next start",
+    "lint": "next lint"
 },

次に、ターミナルでnpm run lintを実行します。

Terminal
npm run lint

以下の警告が表示されるはずです。

Terminal
✔ 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`} // この行を削除
/>

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

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

アプリケーションをVercelにデプロイしようとすると、ビルドログにも警告が表示されます。これは、next lintがビルドプロセスの一部として実行されるためです。そのため、アプリケーションをデプロイする前にアクセシビリティの問題を発見するために、ローカルでlintを実行できます。


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

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

  1. セマンティックHTML<div>の代わりにセマンティック要素(<input><option>など)を使用します。これにより、支援技術(AT)がフォーム要素にフォーカスし、ユーザーに適切なコンテキスト情報を提供できるため、フォームのナビゲーションと理解が容易になります。
  2. ラベリング<label>とhtmlFor属性を含めることで、各フォームフィールドに説明的なテキストラベルがあることを確認します。これにより、コンテキストを提供してATのサポートが向上し、ラベルをクリックして対応する入力フィールドにフォーカスできるようになるため、ユーザビリティも向上します。
  3. フォーカスアウトライン:フィールドは、フォーカスされたときにアウトラインを表示するようにスタイル設定されています。これは、ページ上のアクティブな要素を視覚的に示すために不可欠であり、キーボードとスクリーンリーダーの両方のユーザーがフォーム上の自分の位置を理解するのに役立ちます。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属性を追加した場合はひとまず削除しておいてください。

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

サーバー側でフォームを検証することで、以下のことができます。

  • データベースにデータを送信する前に、データが期待されるフォーマットであることを確認します。
  • 悪意のあるユーザーがクライアント側の検証をバイパスするリスクを減らします。
  • 有効なデータとみなされるものについて、1つの真実の情報源を持ちます。

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

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

Formコンポーネント内で、useFormStateフックは次のようになります。

  • 2つの引数を取ります:(action, initialState)
  • 2つの値を返します:[state, dispatch] - フォームの状態と、ディスパッチ関数(useReducerと同様)。

createInvoiceアクションをuseFormStateの引数として渡し、<form action={}>属性内でdispatchを呼び出します。

/app/ui/invoices/create-form.tsx
 // ...
 import { useFormState } from 'react-dom';
 
 export default function Form({ customers }: { customers: CustomerField[] }) {
+  const [state, dispatch] = useFormState(createInvoice, initialState);
 
+  return <form action={dispatch}>...</form>;
 }

initialStateは定義したものなら何でも構いません。この例では、2つの空のキーmessageerrorsを持つオブジェクトを作成します。

/app/ui/invoices/create-form.tsx
 // ...
 import { useFormState } from 'react-dom';
 
 export default function Form({ customers }: { customers: CustomerField[] }) {
+  const initialState = { message: null, errors: {} };
   const [state, dispatch] = useFormState(createInvoice, initialState);
 
   return <form action={dispatch}>...</form>;
 }

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

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

/app/lib/action.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はすでに文字列型を期待しているため、customerフィールドが空の場合はエラーを投げます。ただし、ユーザーがcustomerを選択しなかった場合のわかりやすいメッセージを追加しましょう。
  • amount - 金額の型をstringからnumberに強制変換しているので、文字列が空の場合は0になります。Zodに、常に0より大きい金額を求めるように.gt()関数で指示しましょう。
  • status - Zodはすでに"pending"または"paid"を期待しているため、ステータスフィールドが空の場合はエラーを投げます。ユーザーがステータスを選択しなかった場合のわかりやすいメッセージも追加しましょう。
メモ:Zodのエラーメッセージ

Zodではスキーマの定義時に各メソッドの引数にエラーメッセージを設定できます。
以下のように複数指定することも可能です。

const name = z.string({
  required_error: "Name is required",
  invalid_type_error: "Name must be a string",
});

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

/app/lib/actions.ts
 // @types/react-domが更新されるまでの一時的なもの
+export type State = {
+  errors?: {
+    customerId?: string[];
+    amount?: string[];  
+    status?: string[];
+  };
+  message?: string | null;
+};
 
+export async function createInvoice(prevState: State, formData: FormData) {
   // ...
 }
  • formData - 以前と同じです。
  • prevState - useFormStateフックから渡された状態を含みます。この例ではアクション内で使用しませんが、必須のプロパティです。

次に、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: 'Missing Fields. Failed to Create Invoice.',
+    };
+  }
   // ...
 }

validatedFieldsが成功しない場合、Zodからのエラーメッセージで関数を早期リターンします。

Tip: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: '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 (error) {
    // データベースエラーが発生した場合は、より具体的なエラーを返す。
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }
 
  // 請求書ページのキャッシュを再検証し、ユーザーをリダイレクトする。
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

それでは、フォームコンポーネントにエラーを表示しましょう。create-form.tsxコンポーネントに戻って、フォームのstateを使用してエラーにアクセスできます。

各エラーをチェックする三項演算子を追加します。例えば、customer'sフィールドの後に、次のように追加します:

/app/ui/invoices/create-form.tsx
 <form action={dispatch}>
   <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">
         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>

Tip:コンポーネント内で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は次のようになるはずです。

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

準備ができたら、npm run lintを実行して、ariaラベルを正しく使用しているかどうかを確認します。

create-form.tsxの変更部分は以下の通りです。

/app/ui/invoices/create-form.tsx
        // ...
        {/* Invoice Amount */}
        <div className="mb-4">
          <label htmlFor="amount" className="mb-2 block text-sm font-medium">
            Choose an amount
          </label>
          <div className="relative mt-2 rounded-md">
            <div className="relative">
              <input
                // ...
                text-sm outline-2 placeholder:text-gray-500"
                aria-describedby="amount-error"
              />
              // ...
          </div>

+          <div id="amount-error" aria-live="polite" aria-atomic="true">
+            {state.errors?.amount &&
+              state.errors.amount.map((error: string) => (
+                <p className="mt-2 text-sm text-red-500" key={error}>
+                  {error}
+                </p>
+              ))}
+          </div>
        </div>

         {/* Invoice Status */}
         <fieldset>
           <legend className="mb-2 block text-sm font-medium">
             Set the invoice status
           </legend>
           <div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
             // ...
           </div>
+          <div id="status-error" aria-live="polite" aria-atomic="true">
+            {state.errors?.status &&
+              state.errors.status.map((error: string) => (
+                <p className="mt-2 text-sm text-red-500" key={error}>
+                  {error}
+                </p>
+              ))}
+          </div>
        </fieldset>

+        <div aria-live="polite" aria-atomic="true">
+          {state.message ? (
+            <p className="mt-2 text-sm text-red-500">{state.message}</p>
+          ) : null}
+        </div>
        // ...
   );
  }

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

以下が必要になります。

  1. edit-form.tsxコンポーネントにuseFormStateを追加する。
  2. updateInvoiceアクションを編集して、Zodからの検証エラーを処理する。
  3. コンポーネントにエラーを表示し、ariaラベルを追加してアクセシビリティを向上させる。

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

解答

請求書編集フォーム:

/app/ui/invoices/edit-form.tsx
export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  const initialState = { message: null, errors: {} };
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
  const [state, dispatch] = useFormState(updateInvoiceWithId, initialState);
 
  return <form action={dispatch}></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');
}

請求書編集フォーム全文:

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

import { CustomerField, InvoiceForm } from '@/app/lib/definitions';
import {
  CheckIcon,
  ClockIcon,
  CurrencyDollarIcon,
  UserCircleIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { Button } from '@/app/ui/button';
import { updateInvoice } from '@/app/lib/actions';
import { useFormState } from 'react-dom';

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

  return (
    <form action={dispatch}>
      <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 cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
              defaultValue={invoice.customer_id}
+             aria-describedby="customer-error"
            >
              <option value="" disabled>
                Select a customer
              </option>
              {customers.map((customer) => (
                <option key={customer.id} value={customer.id}>
                  {customer.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>

        {/* Invoice Amount */}
        <div className="mb-4">
          <label htmlFor="amount" className="mb-2 block text-sm font-medium">
            Choose an amount
          </label>
          <div className="relative mt-2 rounded-md">
            <div className="relative">
              <input
                id="amount"
                name="amount"
                type="number"
                defaultValue={invoice.amount}
                step="0.01"
                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"
+               aria-describedby="amount-error"
              />
              <CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>

+          <div id="amount-error" aria-live="polite" aria-atomic="true">
+           {state.errors?.amount &&
+             state.errors.amount.map((error: string) => (
+               <p className="mt-2 text-sm text-red-500" key={error}>
+                 {error}
+               </p>
+             ))}
+         </div>
        </div>

        {/* Invoice Status */}
        <fieldset>
          <legend className="mb-2 block text-sm font-medium">
            Set the invoice status
          </legend>
          <div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
            <div className="flex gap-4">
              <div className="flex items-center">
                <input
                  id="pending"
                  name="status"
                  type="radio"
                  value="pending"
                  defaultChecked={invoice.status === 'pending'}
                  className="h-4 w-4 border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
                />
                <label
                  htmlFor="pending"
                  className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
                >
                  Pending <ClockIcon className="h-4 w-4" />
                </label>
              </div>
              <div className="flex items-center">
                <input
                  id="paid"
                  name="status"
                  type="radio"
                  value="paid"
                  defaultChecked={invoice.status === 'paid'}
                  className="h-4 w-4 border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
                />
                <label
                  htmlFor="paid"
                  className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
                >
                  Paid <CheckIcon className="h-4 w-4" />
                </label>
              </div>
            </div>
          </div>
+         <div id="status-error" aria-live="polite" aria-atomic="true">
+           {state.errors?.status &&
+             state.errors.status.map((error: string) => (
+               <p className="mt-2 text-sm text-red-500" key={error}>
+                 {error}
+               </p>
+             ))}
+         </div>
       </fieldset>

+       <div aria-live="polite" aria-atomic="true">
+         {state.message ? (
+           <p className="my-2 text-sm text-red-500">{state.message}</p>
+         ) : null}
+       </div>
     </div>
      <div className="mt-6 flex justify-end gap-4">
        <Link
          href="/dashboard/invoices"
          className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
        >
          Cancel
        </Link>
        <Button type="submit">Edit Invoice</Button>
      </div>
    </form>
  );
}

次の章

https://zenn.dev/gunjo/articles/6d3b5c50d95f3c

Discussion