Accessibility

Now let's add some a11y to our form. We'll define the roles and describe the errors so everyone can interact with our app.

190
lines of code
import { Form } from '@remix-run/react'
import { ActionFunction, redirect, json } from '@remix-run/node'
import Label from '~/ui/label'
import Input from '~/ui/input'
import Select from '~/ui/select'
import TextArea from '~/ui/text-area'
import Button from '~/ui/button'
import { useActionData } from '@remix-run/react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'

function parseDate(value: unknown) {
  const [year, month, day] = String(value).split('-').map(Number)
  return new Date(year, month - 1, day)
}

const reservationSchema = z.object({
  city: z.enum(['saltLakeCity', 'lasVegas', 'losAngeles']),
  checkIn: z.preprocess(parseDate, z.date()),
  checkOut: z.preprocess(parseDate, z.date()),
  adults: z.preprocess(Number, z.number().int().positive()),
  children: z.preprocess(Number, z.number().int()),
  bedrooms: z.preprocess(Number, z.number().int().positive()),
  specialRequests: z.string().optional(),
})

async function makeReservation(values: z.infer<typeof reservationSchema>) {
  // Here you would store data instead
  console.log(values)
}

type ActionData = { errors: z.ZodIssue[] }

export const action: ActionFunction = async ({ request }) => {
  const formValues = Object.fromEntries(await request.formData())
  const result = reservationSchema.safeParse(formValues)

  if (result.success) {
    await makeReservation(result.data)
    return redirect('conf/success/06')
  }

  return json<ActionData>({ errors: result.error.issues })
}

function Error(props: JSX.IntrinsicElements['div']) {
  return <div {...props} className="mt-1 text-red-500" role="alert" />
}

function ServerError({ name }: { name: string }) {
  const errors = useActionData<ActionData>()?.errors
  const message = errors?.find(({ path }) => path[0] === name)?.message

  if (!message) return null

  return <Error id={`error-for-${name}`}>{message}</Error>
}

function FieldError({ name, errors }: { name: string; errors: any }) {
  const message = errors[name]?.message

  if (message) {
    return <Error id={`error-for-${name}`}>{message}</Error>
  }

  return <ServerError name={name} />
}

export default function Component() {
  const resolver = zodResolver(reservationSchema)
  const { register, handleSubmit, formState } = useForm({ resolver })
  const { errors } = formState
  const submit = useSubmit()
  const transition = useTransition()
  const submitting = Boolean(transition.submission)
  const serverErrors = useActionData<ActionData>()?.errors

  function serverErrorFor(name: string) {
    return serverErrors?.find(({ path }) => path[0] === name)?.message
  }

  const hasErrors = (name: string) =>
    Boolean(serverErrorFor(name) || errors[name])

  const describedBy = (name: string) =>
    hasErrors(name) ? `error-for-${name}` : undefined

  return (
    <Form
      method="post"
      className="flex flex-col space-y-4"
      onSubmit={(event: any) => {
        handleSubmit(() => submit(event.target))(event)
      }}
    >
      <div>
        <Label id="label-for-city" htmlFor="city">
          City
        </Label>
        <Select
          {...register('city')}
          id="city"
          aria-labelledby="label-for-city"
          aria-required
          aria-invalid={hasErrors('city')}
          aria-describedby={describedBy('city')}
        >
          <option value="saltLakeCity">Salt Lake City</option>
          <option value="lasVegas">Las Vegas</option>
          <option value="losAngeles">Los Angeles</option>
        </Select>
        <FieldError name="city" errors={errors} />
      </div>
      <div className="flex w-full space-x-4">
        <div className="flex-1">
          <Label id="label-for-check-in" htmlFor="checkIn">
            Check In
          </Label>
          <Input
            {...register('checkIn')}
            id="checkIn"
            type="date"
            aria-labelledby="label-for-check-in"
            aria-required
            aria-invalid={hasErrors('checkIn')}
            aria-describedby={describedBy('checkIn')}
          />
          <FieldError name="checkIn" errors={errors} />
        </div>
        <div className="flex-1">
          <Label id="label-for-check-out" htmlFor="checkOut">
            Check Out
          </Label>
          <Input
            {...register('checkOut')}
            id="checkOut"
            type="date"
            aria-labelledby="label-for-check-out"
            aria-required
            aria-invalid={hasErrors('checkOut')}
            aria-describedby={describedBy('checkOut')}
          />
          <FieldError name="checkOut" errors={errors} />
        </div>
      </div>
      <div className="flex w-full space-x-4">
        <div className="flex-1">
          <Label id="label-for-adults" htmlFor="adults">
            Adults
          </Label>
          <Input
            {...register('adults')}
            id="adults"
            aria-labelledby="label-for-adults"
            aria-required
            aria-invalid={hasErrors('adults')}
            aria-describedby={describedBy('adults')}
          />
          <FieldError name="adults" errors={errors} />
        </div>
        <div className="flex-1">
          <Label id="label-for-children" htmlFor="children">
            Children
          </Label>
          <Input
            {...register('children')}
            id="children"
            aria-labelledby="label-for-children"
            aria-required
            aria-invalid={hasErrors('children')}
            aria-describedby={describedBy('children')}
          />
          <FieldError name="children" errors={errors} />
        </div>
        <div className="flex-1">
          <Label id="label-for-bedrooms" htmlFor="bedrooms">
            Bedrooms
          </Label>
          <Input
            {...register('bedrooms')}
            id="bedrooms"
            aria-labelledby="label-for-bedrooms"
            aria-required
            aria-invalid={hasErrors('bedrooms')}
            aria-describedby={describedBy('bedrooms')}
          />
          <FieldError name="bedrooms" errors={errors} />
        </div>
      </div>
      <div>
        <Label id="label-for-special-requests" htmlFor="specialRequests">
          Special Requests
        </Label>
        <TextArea
          {...register('specialRequests')}
          id="specialRequests"
          aria-labelledby="label-for-special-requests"
          aria-invalid={hasErrors('specialRequests')}
          aria-describedby={describedBy('specialRequests')}
        />
        <FieldError name="specialRequests" errors={errors} />
      </div>
      <Button disabled={submitting} className="w-48">
        {submitting ? '...' : 'Make reservation'}
      </Button>
    </Form>
  )
}