跳至内容

14

提升可访问性

在上一章中,我们了解了如何捕获错误(包括 404 错误)并向用户显示回退内容。但是,我们还需要讨论拼图的另一块:表单验证。让我们看看如何使用服务器操作实现服务器端验证,以及如何在保持可访问性的前提下使用 React 的 useActionState 钩子显示表单错误!

在本节中…

以下是我们将涵盖的主题

如何将 eslint-plugin-jsx-a11y 与 Next.js 结合使用以实现可访问性最佳实践。

如何实现服务器端表单验证。

如何使用 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

虽然添加和配置代码检查器不是必需的步骤,但它有助于在您的开发过程中捕获可访问性问题。

改进表单的可访问性

我们已经在表单中做了三件事来提升可访问性

  • 语义 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 钩子。由于 useActionState 是一个钩子,因此您需要使用 "use client" 指令将您的表单转换为客户端组件

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

在您的表单组件内部,useActionState 钩子

  • 接受两个参数:(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>;
}

最初这可能看起来很令人困惑,但一旦您更新了服务器操作,它就会更有意义。让我们现在就开始做。

在您的 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 action 以接受两个参数 - 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 传递的状态。在本例中,您不会在 action 中使用它,但它是一个必需的属性。

然后,将 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 到您的组件中,并检查所有内容是否正确连接。由于您的表单现在是客户端组件,请在 Dev Tools 中检查控制台。

在上面的代码中,您还添加了以下 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 组件中。

您需要

  • useActionState 添加到您的 edit-form.tsx 组件中。
  • 编辑 updateInvoice action 以处理来自 Zod 的验证错误。
  • 在您的组件中显示错误,并添加 aria 标签以提高可访问性。

准备就绪后,展开下面的代码片段以查看解决方案

您已完成本章14

很好,您已经学习了如何使用 React Form Status 和服务器端验证来提高表单的可访问性。

下一步

15:添加身份验证

您的应用程序即将准备就绪,在下一章中,您将学习如何使用 NextAuth.js 为您的应用程序添加身份验证。