跳至內容

14

提升無障礙網頁體驗

在上一章節中,我們探討了如何捕捉錯誤(包括 404 錯誤)並向使用者顯示備援方案。然而,我們還需要討論另一個關鍵環節:表單驗證。讓我們來看看如何使用伺服器動作(Server Actions)實作伺服器端驗證,以及如何使用 React 的 useActionState hook 顯示表單錯誤,同時兼顧無障礙網頁體驗!

本章節內容

我們將涵蓋以下主題

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

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

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

什麼是無障礙網頁設計?

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

雖然在本課程中我們不會深入探討無障礙網頁設計,但我們會討論 Next.js 中提供的無障礙功能,以及一些使應用程式更易於存取的常見做法。

如果您想深入瞭解無障礙網頁設計,我們推薦由 web.dev 所提供的 學習無障礙網頁設計 課程。

在 Next.js 中使用 ESLint 無障礙外掛

Next.js 在其 ESLint 設定中包含了 eslint-plugin-jsx-a11y 外掛,以協助及早發現無障礙問題。例如,如果圖片沒有 alt 文字、錯誤使用 aria-*role 屬性等等,這個外掛會發出警告。

(選用)如果您想試用看看,請在 package.json 檔案中新增 next lint 作為一個 script。

/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

雖然新增和設定程式碼檢查器並非必要步驟,但在開發過程中它有助於及早發現無障礙問題。

改善表單的無障礙功能

我們已經在三個方面著手改善表單的無障礙功能

  • 語義化 HTML:使用語義化元素(<input><option> 等)而不是 <div>。這讓輔助技術 (AT) 能夠專注於輸入元素,並向使用者提供適當的上下文資訊,使表單更容易瀏覽和理解。
  • 標籤:包含 <label>htmlFor 屬性可確保每個表單欄位都有描述性文字標籤。這透過提供上下文來改善輔助技術支援,也藉由允許使用者點擊標籤來聚焦對應的輸入欄位,從而提升可用性。
  • 焦點框線:欄位經過適當的樣式設計,使其在獲得焦點時顯示框線。這對於無障礙功能至關重要,因為它以視覺方式指示頁面上的活動元素,幫助鍵盤和螢幕閱讀器使用者了解他們在表單上的位置。您可以透過按下 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 類型。

/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>;
}

一開始這可能看起來有點 confusing,但一旦您更新伺服器動作後就會更容易理解。現在就讓我們來做這件事。

在您的 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() 函式告訴 Zod 我們總是希望金額大於 0。
  • 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 並檢查所有內容是否已正確連接。由於您的表單現在是 Client Component,請檢查開發工具中的控制台。

在上面的程式碼中,您還新增了以下的 ARIA 標籤

  • aria-describedby="customer-error":這建立了 select 元素和錯誤訊息容器之間的關係。它表示 id="customer-error" 的容器描述了 select 元素。當使用者與下拉式選單互動時,螢幕閱讀器會讀取此描述以通知他們錯誤。
  • id="customer-error":這個 id 屬性唯一識別了包含 select 輸入錯誤訊息的 HTML 元素。這對於 aria-describedby 建立關係是必要的。
  • aria-live="polite":當 div 內的錯誤更新時,螢幕閱讀器應禮貌地通知使用者。當內容發生變化時(例如,當使用者更正錯誤時),螢幕閱讀器會播報這些變化,但只會在使用者空閒時播報,以免打斷他們。

練習:新增 aria 標籤

使用上面的範例,將錯誤訊息新增到其餘的表單欄位中。如果缺少任何欄位,您也應該在表單底部顯示訊息。您的使用者介面應該如下所示

Create invoice form showing error messages for each field.

準備好後,請執行 pnpm lint 來檢查您是否正確使用了 aria 標籤。

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

您需要

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

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

您已完成本章14

太好了,您已經學會了如何使用 React 表單狀態和伺服器端驗證來改善表單的無障礙性。

下一步

15:新增驗證功能

您的應用程式即將準備就緒,在下一章中,您將學習如何使用 NextAuth.js 為您的應用程式新增驗證功能。