Mejorando la Accesibilidad

En el capítulo anterior, vimos cómo capturar errores (incluyendo errores 404) y mostrar una alternativa al usuario. Sin embargo, aún nos falta discutir otra pieza del rompecabezas: la validación de formularios. Veamos cómo implementar validación en el servidor con Acciones del Servidor (Server Actions), y cómo puedes mostrar errores de formulario usando el hook useActionState de React - ¡todo esto manteniendo la accesibilidad en mente!

¿Qué es la accesibilidad?

La accesibilidad se refiere al diseño e implementación de aplicaciones web que todos puedan usar, incluyendo personas con discapacidades. Es un tema amplio que cubre muchas áreas, como navegación por teclado, HTML semántico, imágenes, colores, videos, etc.

Aunque no profundizaremos en accesibilidad en este curso, discutiremos las características de accesibilidad disponibles en Next.js y algunas prácticas comunes para hacer tus aplicaciones más accesibles.

Si deseas aprender más sobre accesibilidad, recomendamos el curso Learn Accessibility de web.dev.

Usando el plugin de accesibilidad ESLint en Next.js

Next.js incluye el plugin eslint-plugin-jsx-a11y en su configuración ESLint para ayudar a detectar problemas de accesibilidad temprano. Por ejemplo, este plugin advierte si tienes imágenes sin texto alt, usas atributos aria-* y role incorrectamente, entre otros.

Opcionalmente, si deseas probarlo, agrega next lint como un script en tu archivo package.json:

/package.json
"scripts": {
    "build": "next build",
    "dev": "next dev",
    "start": "next start",
    "lint": "next lint"
},

Luego ejecuta pnpm lint en tu terminal:

Terminal
pnpm lint

Esto te guiará a través de la instalación y configuración de ESLint para tu proyecto. Si ejecutaras pnpm lint ahora, deberías ver el siguiente resultado:

Terminal
 No ESLint warnings or errors

Sin embargo, ¿qué pasaría si tuvieras una imagen sin texto alt? ¡Vamos a descubrirlo!

Ve a /app/ui/invoices/table.tsx y elimina el prop alt de la imagen. Puedes usar la función de búsqueda de tu editor para encontrar rápidamente el <Image>:

/app/ui/invoices/table.tsx
<Image
  src={invoice.image_url}
  className="rounded-full"
  width={28}
  height={28}
  alt={`${invoice.name}'s profile picture`} // Elimina esta línea
/>

Ahora ejecuta pnpm lint nuevamente, y deberías ver la siguiente advertencia:

Terminal
./app/ui/invoices/table.tsx
45:25  Warning: Los elementos Image deben tener un prop alt,
ya sea con texto significativo o una cadena vacía para imágenes decorativas. jsx-a11y/alt-text

Aunque agregar y configurar un linter no es un paso obligatorio, puede ser útil para detectar problemas de accesibilidad en tu proceso de desarrollo.

Mejorando la accesibilidad de formularios

Hay tres cosas que ya estamos haciendo para mejorar la accesibilidad en nuestros formularios:

  • HTML semántico: Usar elementos semánticos (<input>, <option>, etc) en lugar de <div>. Esto permite que las tecnologías de asistencia (AT) se enfoquen en los elementos de entrada y proporcionen información contextual adecuada al usuario, haciendo el formulario más fácil de navegar y entender.
  • Etiquetado: Incluir <label> y el atributo htmlFor asegura que cada campo del formulario tenga una etiqueta de texto descriptiva. Esto mejora el soporte para AT al proporcionar contexto y también mejora la usabilidad al permitir que los usuarios hagan clic en la etiqueta para enfocar el campo de entrada correspondiente.
  • Contorno de enfoque: Los campos están correctamente estilizados para mostrar un contorno cuando están enfocados. Esto es crítico para la accesibilidad ya que indica visualmente el elemento activo en la página, ayudando tanto a usuarios de teclado como de lectores de pantalla a entender dónde están en el formulario. Puedes verificarlo presionando tab.

Estas prácticas establecen una buena base para hacer tus formularios más accesibles para muchos usuarios. Sin embargo, no abordan la validación de formularios y los errores.

Validación de formularios

Ve a http://localhost:3000/dashboard/invoices/create, y envía un formulario vacío. ¿Qué sucede?

¡Obtienes un error! Esto ocurre porque estás enviando valores vacíos del formulario a tu Acción del Servidor (Server Action). Puedes prevenir esto validando tu formulario en el cliente o en el servidor.

Validación en el cliente

Hay un par de formas en que puedes validar formularios en el cliente. La más simple sería confiar en la validación de formularios proporcionada por el navegador agregando el atributo required a los elementos <input> y <select> en tus formularios. Por ejemplo:

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

Envía el formulario nuevamente. El navegador mostrará una advertencia si intentas enviar un formulario con valores vacíos.

Este enfoque generalmente está bien porque algunas ATs soportan la validación del navegador.

Una alternativa a la validación en el cliente es la validación en el servidor. Veamos cómo puedes implementarla en la siguiente sección. Por ahora, elimina los atributos required si los agregaste.

Validación del lado del servidor (Server-Side validation)

Al validar formularios en el servidor, puedes:

  • Asegurar que tus datos tengan el formato esperado antes de enviarlos a tu base de datos.
  • Reducir el riesgo de que usuarios malintencionados eviten la validación del lado del cliente (client-side validation).
  • Tener una única fuente de verdad sobre lo que se considera datos válidos.

En tu componente create-form.tsx, importa el hook useActionState de react. Como useActionState es un hook, necesitarás convertir tu formulario en un Componente de Cliente (Client Component) usando la directiva "use client":

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

Dentro de tu Componente de Formulario, el hook useActionState:

  • Toma dos argumentos: (action, initialState).
  • Retorna dos valores: [state, formAction] - el estado del formulario y una función que se llama cuando se envía el formulario.

Pasa tu acción createInvoice como argumento de useActionState, y dentro del atributo <form action={}>, llama a 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>;
}

El initialState puede ser cualquier cosa que definas. En este caso, crea un objeto con dos claves vacías: message y errors, e importa el tipo State de tu archivo actions.ts. State aún no existe, pero lo crearemos a continuación:

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

Esto puede parecer confuso al principio, pero tendrá más sentido una vez que actualices la acción del servidor. Hagámoslo ahora.

En tu archivo action.ts, puedes usar Zod para validar los datos del formulario. Actualiza tu FormSchema de la siguiente manera:

/app/lib/actions.ts
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: 'Por favor selecciona un cliente.',
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: 'Por favor ingresa un monto mayor a $0.' }),
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: 'Por favor selecciona un estado de factura.',
  }),
  date: z.string(),
});
  • customerId - Zod ya lanza un error si el campo del cliente está vacío, ya que espera un tipo string. Pero agreguemos un mensaje amigable si el usuario no selecciona un cliente.
  • amount - Como estás convirtiendo el tipo de monto de string a number, por defecto será cero si el string está vacío. Indiquémosle a Zod que siempre queremos que el monto sea mayor que 0 con la función .gt().
  • status - Zod ya lanza un error si el campo de estado está vacío, ya que espera "pending" o "paid". También agreguemos un mensaje amigable si el usuario no selecciona un estado.

A continuación, actualiza tu acción createInvoice para aceptar dos parámetros: prevState y formData:

/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 - igual que antes.
  • prevState - contiene el estado pasado desde el hook useActionState. No lo usarás en la acción en este ejemplo, pero es una propiedad requerida.

Luego, cambia la función parse() de Zod a safeParse():

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Validar campos del formulario usando Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // ...
}

safeParse() retornará un objeto que contiene un campo success o error. Esto ayudará a manejar la validación de manera más elegante sin tener que poner esta lógica dentro de un bloque try/catch.

Antes de enviar la información a tu base de datos, verifica si los campos del formulario se validaron correctamente con un condicional:

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Validar campos del formulario usando Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // Si la validación del formulario falla, retorna los errores temprano. De lo contrario, continúa.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Campos faltantes. No se pudo crear la factura.',
    };
  }
 
  // ...
}

Si validatedFields no es exitoso, retornamos la función temprano con los mensajes de error de Zod.

Consejo: console.log validatedFields y envía un formulario vacío para ver su estructura.

Finalmente, como estás manejando la validación del formulario por separado, fuera de tu bloque try/catch, puedes retornar un mensaje específico para cualquier error de base de datos. Tu código final debería verse así:

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Validar formulario usando Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // Si la validación del formulario falla, retorna los errores temprano. De lo contrario, continúa.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Campos faltantes. No se pudo crear la factura.',
    };
  }
 
  // Preparar datos para inserción en la base de datos
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  // Insertar datos en la base de datos
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    // Si ocurre un error en la base de datos, retorna un error más específico.
    return {
      message: 'Error de base de datos: No se pudo crear la factura.',
    };
  }
 
  // Revalidar la caché para la página de facturas y redirigir al usuario.
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

Genial, ahora mostremos los errores en tu componente de formulario. De vuelta en el componente create-form.tsx, puedes acceder a los errores usando el state del formulario.

Agrega un operador ternario que verifique cada error específico. Por ejemplo, después del campo del cliente, puedes agregar:

/app/ui/invoices/create-form.tsx
<form action={formAction}>
  <div className="rounded-md bg-gray-50 p-4 md:p-6">
    {/* Nombre del Cliente */}
    <div className="mb-4">
      <label htmlFor="customer" className="mb-2 block text-sm font-medium">
        Elegir cliente
      </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>
            Seleccionar un cliente
          </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>

Consejo: Puedes hacer console.log de state dentro de tu componente y verificar si todo está conectado correctamente. Revisa la consola en Dev Tools ya que tu formulario ahora es un Componente de Cliente.

En el código anterior, también estás agregando las siguientes etiquetas aria:

  • aria-describedby="customer-error": Establece una relación entre el elemento select y el contenedor de mensajes de error. Indica que el contenedor con id="customer-error" describe el elemento select. Los lectores de pantalla leerán esta descripción cuando el usuario interactúe con el cuadro select para notificarles los errores.
  • id="customer-error": Este atributo id identifica de manera única el elemento HTML que contiene el mensaje de error para el input select. Esto es necesario para que aria-describedby establezca la relación.
  • aria-live="polite": El lector de pantalla debe notificar cortésmente al usuario cuando se actualice el error dentro del div. Cuando el contenido cambie (por ejemplo, cuando un usuario corrija un error), el lector de pantalla anunciará estos cambios, pero solo cuando el usuario esté inactivo para no interrumpirlo.

Práctica: Agregar etiquetas aria

Usando el ejemplo anterior, agrega errores a los campos restantes de tu formulario. También deberías mostrar un mensaje al final del formulario si faltan campos. Tu interfaz debería verse así:

Formulario de creación de factura mostrando mensajes de error para cada campo.

Una vez que estés listo, ejecuta pnpm lint para verificar si estás usando las etiquetas aria correctamente.

Si quieres desafiarte, toma el conocimiento que has aprendido en este capítulo y agrega validación de formulario al componente edit-form.tsx.

Necesitarás:

  • Agregar useActionState a tu componente edit-form.tsx.
  • Editar la acción updateInvoice para manejar errores de validación de Zod.
  • Mostrar los errores en tu componente y agregar etiquetas aria para mejorar la accesibilidad.

Una vez que estés listo, expande el siguiente fragmento de código para ver la solución:

On this page