14
章節14
提升無障礙網頁體驗
在上一章節中,我們探討了如何捕捉錯誤(包括 404 錯誤)並向使用者顯示備援方案。然而,我們還需要討論另一個關鍵環節:表單驗證。讓我們來看看如何使用伺服器動作(Server Actions)實作伺服器端驗證,以及如何使用 React 的 useActionState
hook 顯示表單錯誤,同時兼顧無障礙網頁體驗!
本章節內容
我們將涵蓋以下主題
如何在 Next.js 中使用 eslint-plugin-jsx-a11y
實作無障礙網頁最佳實務。
如何實作伺服器端表單驗證。
如何使用 React 的 useActionState
hook 處理表單錯誤,並將錯誤顯示給使用者。
什麼是無障礙網頁設計?
無障礙網頁設計是指設計和實作所有人都能使用的網頁應用程式,包括身心障礙人士。這是一個涵蓋許多領域的廣泛主題,例如鍵盤導航、語義化 HTML、圖片、顏色、影片等等。
雖然在本課程中我們不會深入探討無障礙網頁設計,但我們會討論 Next.js 中提供的無障礙功能,以及一些使應用程式更易於存取的常見做法。
在 Next.js 中使用 ESLint 無障礙外掛
Next.js 在其 ESLint 設定中包含了 eslint-plugin-jsx-a11y
外掛,以協助及早發現無障礙問題。例如,如果圖片沒有 alt
文字、錯誤使用 aria-*
和 role
屬性等等,這個外掛會發出警告。
(選用)如果您想試用看看,請在 package.json
檔案中新增 next lint
作為一個 script。
"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>
<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
屬性,來仰賴瀏覽器提供的表單驗證功能。例如:
<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"
指令將表單轉換為客戶端元件。
'use client';
// ...
import { useActionState } from 'react';
在你的表單元件內,useActionState
hook…
- 接受兩個參數:
(action, initialState)
。 - 返回兩個值:
[state, formAction]
- 表單狀態,以及一個在表單提交時要調用的函式。
將您的 createInvoice
動作作為 useActionState
的參數傳入,並在您的 <form action={}>
屬性內調用 formAction
。
// ...
import { useActionState } from 'react';
export default function Form({ customers }: { customers: CustomerField[] }) {
const [state, formAction] = useActionState(createInvoice, initialState);
return <form action={formAction}>...</form>;
}
initialState
可以是您定義的任何內容,在這種情況下,創建一個包含兩個空鍵的物件:message
和 errors
,並從您的 actions.ts
檔案中導入 State
類型。
// ...
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
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
動作以接受兩個參數 - prevState
和 formData
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()
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
區塊內。
在將資訊發送到您的資料庫之前,請使用條件式檢查表單欄位是否已正確驗證。
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 區塊之外單獨處理表單驗證,因此您可以為任何資料庫錯誤返回特定訊息,您的最終程式碼應如下所示
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
來存取錯誤。
新增一個檢查每個特定錯誤的**三元運算子**。例如,在客戶欄位之後,您可以新增
<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 標籤
使用上面的範例,將錯誤訊息新增到其餘的表單欄位中。如果缺少任何欄位,您也應該在表單底部顯示訊息。您的使用者介面應該如下所示


準備好後,請執行 pnpm lint
來檢查您是否正確使用了 aria 標籤。
如果您想要挑戰自己,請運用您在本章學到的知識,將表單驗證新增到 edit-form.tsx
元件中。
您需要
- 將
useActionState
新增到您的edit-form.tsx
元件中。 - 編輯
updateInvoice
動作以處理來自 Zod 的驗證錯誤。 - 在您的元件中顯示錯誤,並新增 aria 標籤以提高無障礙性。
準備好後,展開下方的程式碼片段以查看解決方案
您已完成本章14
太好了,您已經學會了如何使用 React 表單狀態和伺服器端驗證來改善表單的無障礙性。
這有幫助嗎?