跳到內容

14

改善無障礙功能

在前一章中,我們探討了如何捕捉錯誤(包括 404 錯誤)並向使用者顯示備用方案。然而,我們仍需討論拼圖的另一塊:表單驗證。讓我們看看如何使用伺服器動作實作伺服器端驗證,以及如何使用 React 的 useActionState Hook 顯示表單錯誤 - 同時兼顧無障礙功能!

本章內容...

以下是我們將涵蓋的主題

如何在 Next.js 中使用 eslint-plugin-jsx-a11y 實作無障礙功能最佳實務。

如何實作伺服器端表單驗證。

如何使用 React useActionState Hook 處理表單錯誤,並將其顯示給使用者。

什麼是無障礙功能?

無障礙功能是指設計和實作每個人(包括身心障礙人士)都能使用的 Web 應用程式。這是一個廣泛的主題,涵蓋許多領域,例如鍵盤導航、語意化 HTML、圖片、色彩、影片等等。

雖然我們在本課程中不會深入探討無障礙功能,但我們會討論 Next.js 中可用的無障礙功能,以及一些使您的應用程式更具無障礙功能的常見實務。

如果您想進一步了解無障礙功能,我們推薦 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

雖然新增和設定 linter 不是必要步驟,但它有助於在您的開發過程中發現無障礙功能問題。

改善表單無障礙功能

我們已經在表單中做了三件事來改善無障礙功能

  • 語意化 HTML:使用語意化元素(<input><option> 等)而不是 <div>。這讓輔助科技 (AT) 能夠專注於輸入元素,並向使用者提供適當的上下文資訊,使表單更易於導航和理解。
  • 標籤:包含 <label>htmlFor 屬性可確保每個表單欄位都有描述性的文字標籤。這透過提供上下文來改善 AT 支援,並透過允許使用者點擊標籤來專注於對應的輸入欄位,從而增強可用性。
  • 焦點外框:欄位已正確設定樣式,以便在獲得焦點時顯示外框。這對於無障礙功能至關重要,因為它可以視覺化地指示頁面上的活動元素,協助鍵盤和螢幕閱讀器使用者了解他們在表單上的位置。您可以透過按下 tab 鍵來驗證這一點。

這些實務為使您的表單對許多使用者更具無障礙功能奠定了良好的基礎。但是,它們並未解決表單驗證錯誤

表單驗證

前往 https://127.0.0.1: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 Hook。由於 useActionState 是一個 Hook,您需要使用 "use client" 指令將您的表單變成用戶端元件

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

在您的表單元件內部,useActionState Hook

  • 接受兩個引數:(action, initialState)
  • 傳回兩個值:[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,並從您的 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 - 由於您要將金額類型從 string 強制轉換為 number,如果字串為空白,它將預設為零。讓我們告訴 Zod 我們始終希望金額大於 0,並使用 .gt() 函式。
  • status - 如果狀態欄位為空白,Zod 已經會擲回錯誤,因為它預期為「pending」或「paid」。如果使用者未選取狀態,讓我們也新增一個友善的訊息。

接下來,更新您的 createInvoice 動作以接受兩個參數 - 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 Hook 傳遞的狀態。您不會在此範例的動作中使用它,但它是必要的屬性。

然後,將 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() 將傳回一個物件,其中包含 successerror 欄位。這將有助於更優雅地處理驗證,而無需將此邏輯放入 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 的錯誤訊息。

提示: console.log 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>

提示: 您可以在元件內部 console.log state,並檢查是否所有項目都已正確連接。檢查開發人員工具中的主控台,因為您的表單現在是用戶端元件。

在上述程式碼中,您也新增了以下 aria 標籤

  • aria-describedby="customer-error":這會在 select 元素和錯誤訊息容器之間建立關聯。它表示具有 id="customer-error" 的容器描述了 select 元素。當使用者與 select 方塊互動以通知他們錯誤時,螢幕閱讀器將讀取此描述。
  • id="customer-error":此 id 屬性唯一識別 HTML 元素,該元素保存 select 輸入的錯誤訊息。這是 aria-describedby 建立關聯所必需的。
  • aria-live="polite":當 div 內部的錯誤更新時,螢幕閱讀器應禮貌地通知使用者。當內容變更時(例如,當使用者更正錯誤時),螢幕閱讀器將宣告這些變更,但僅在使用者閒置時才宣告,以免打斷他們。

練習:新增 aria 標籤

使用上述範例,將錯誤新增至您剩餘的表單欄位。如果遺失任何欄位,您也應該在表單底部顯示訊息。您的 UI 應如下所示

Create invoice form showing error messages for each field.

準備就緒後,執行 pnpm lint 以檢查您是否正確使用 aria 標籤。

如果您想挑戰自己,請運用您在本章中學到的知識,將表單驗證新增至 edit-form.tsx 元件。

您需要

  • useActionState 新增至您的 edit-form.tsx 元件。
  • 編輯 updateInvoice 動作以處理來自 Zod 的驗證錯誤。
  • 在您的元件中顯示錯誤,並新增 aria 標籤以改善無障礙功能。

準備就緒後,展開下方的程式碼片段以查看解決方案

您已完成章節14

太棒了,您已學會如何使用 React Form Status 和伺服器端驗證來改善表單的無障礙功能。

下一步

15:新增驗證

您的應用程式即將完成,在下一章中,您將學習如何使用 NextAuth.js 將驗證新增至您的應用程式。