# Form in a Dialog

> Build an invoice while a dialog validates each line item against its own schema.

A shadcn/ui block from the [@flx](https://ui.flexnative.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/flx/rhf-advanced-02).

- Page, with a live preview: https://shadcn.exchange/r/flx/rhf-advanced-02
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/flx/rhf-advanced-02-light-4c7d354787110067.png
- Registry item: https://ui.flexnative.com/r/rhf-advanced-02.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.flexnative.com/r/rhf-advanced-02.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@hookform/resolvers`, `lucide-react`, `react-hook-form`, `sonner`, `zod`
- Registry items: `card`, `dialog`, `empty`, `field`, `input`, `button`

## Files

### `registry/forms/react-hook-form/advanced/rhf-advanced-02.tsx`

```tsx
'use client'

import { Controller, useFieldArray, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { ReceiptTextIcon, Trash2Icon } from 'lucide-react'
import { toast } from 'sonner'
import * as z from 'zod'

import { Button } from '@/components/ui/button'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/components/ui/empty'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'

import {
  LineItemDialog,
  lineItemSchema,
  type LineItem,
} from './rhf-advanced-02-line-item-dialog'

const formSchema = z.object({
  client: z.string().min(1, 'Client name is required.'),
  items: z.array(lineItemSchema).min(1, 'Add at least one line item.'),
})

type FormValues = z.infer<typeof formSchema>

function lineTotal(item: LineItem) {
  return Number(item.quantity) * Number(item.unitPrice)
}

function money(value: number) {
  return `$${value.toFixed(2)}`
}

export function RhfAdvanced02() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { client: '', items: [] },
  })

  const { fields, append, remove } = useFieldArray({
    control: form.control,
    name: 'items',
  })

  const total = fields.reduce((sum, item) => sum + lineTotal(item), 0)

  function onSubmit(data: FormValues) {
    toast.success('Invoice created', {
      description: `${data.items.length} item(s) · ${money(total)}`,
    })
  }

  return (
    <Card className="w-full max-w-lg">
      <CardHeader>
        <CardTitle>Create invoice</CardTitle>
        <CardDescription>
          Add a client, then build the line items one at a time.
        </CardDescription>
      </CardHeader>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="flex flex-col gap-3"
      >
        <CardContent className="flex flex-col gap-6">
          <Controller
            control={form.control}
            name="client"
            render={({ field, fieldState }) => (
              <Field data-invalid={fieldState.invalid}>
                <FieldLabel htmlFor="rhf-advanced-02-client">Client</FieldLabel>
                <Input
                  {...field}
                  id="rhf-advanced-02-client"
                  placeholder="Acme Inc."
                  aria-invalid={fieldState.invalid}
                />
                {fieldState.invalid && (
                  <FieldError errors={[fieldState.error]} />
                )}
              </Field>
            )}
          />

          <div className="flex flex-col gap-3">
            <div className="flex items-center justify-between">
              <span className="text-sm font-medium">Line items</span>
              {fields.length > 0 && (
                <span className="text-muted-foreground text-sm tabular-nums">
                  Total {money(total)}
                </span>
              )}
            </div>

            {fields.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <ReceiptTextIcon />
                  </EmptyMedia>
                  <EmptyTitle>No line items</EmptyTitle>
                  <EmptyDescription>
                    Add billable items to build out this invoice.
                  </EmptyDescription>
                </EmptyHeader>
              </Empty>
            ) : (
              <ul className="flex flex-col gap-2">
                {fields.map((item, index) => (
                  <li
                    key={item.id}
                    className="bg-muted/30 flex items-center justify-between gap-3 rounded-lg border px-3 py-2"
                  >
                    <div className="min-w-0">
                      <p className="truncate text-sm">{item.description}</p>
                      <p className="text-muted-foreground text-xs tabular-nums">
                        {item.quantity} × {money(Number(item.unitPrice))}
                      </p>
                    </div>
                    <div className="flex items-center gap-1">
                      <span className="text-sm tabular-nums">
                        {money(lineTotal(item))}
                      </span>
                      <Button
                        type="button"
                        variant="ghost"
                        size="icon-sm"
                        aria-label="Remove item"
                        onClick={() => remove(index)}
                      >
                        <Trash2Icon className="size-4" />
                      </Button>
                    </div>
                  </li>
                ))}
              </ul>
            )}

            <LineItemDialog onAdd={(item) => append(item)} />
            <Controller
              control={form.control}
              name="items"
              render={({ fieldState }) =>
                fieldState.error?.root ? (
                  <FieldError errors={[fieldState.error.root]} />
                ) : (
                  <></>
                )
              }
            />
          </div>
        </CardContent>
        <CardFooter className="justify-end">
          <Button type="submit" size="sm" disabled={fields.length === 0}>
            Create invoice
          </Button>
        </CardFooter>
      </form>
    </Card>
  )
}
```

### `registry/forms/react-hook-form/advanced/rhf-advanced-02-line-item-dialog.tsx`

```tsx
'use client'

import { useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { PlusIcon } from 'lucide-react'
import * as z from 'zod'

import { Button } from '@/components/ui/button'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog'
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'

export const lineItemSchema = z.object({
  description: z.string().min(1, 'Describe the item.'),
  quantity: z
    .string()
    .min(1, 'Required.')
    .refine(
      (v) => Number.isInteger(Number(v)) && Number(v) >= 1,
      'Use a whole number, 1 or more.',
    ),
  unitPrice: z
    .string()
    .min(1, 'Required.')
    .refine((v) => Number(v) > 0, 'Must be greater than 0.'),
})

export type LineItem = z.infer<typeof lineItemSchema>

const emptyItem: LineItem = { description: '', quantity: '1', unitPrice: '' }

export function LineItemDialog({
  onAdd,
}: Readonly<{ onAdd: (item: LineItem) => void }>) {
  const [open, setOpen] = useState(false)
  const form = useForm<LineItem>({
    resolver: zodResolver(lineItemSchema),
    defaultValues: emptyItem,
  })

  function handleAdd(data: LineItem) {
    onAdd(data)
    form.reset(emptyItem)
    setOpen(false)
  }

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next)
        if (!next) form.reset(emptyItem)
      }}
    >
      <DialogTrigger
        render={
          <Button type="button" variant="outline" size="sm" className="w-full">
            <PlusIcon className="size-4" />
            Add line item
          </Button>
        }
      />
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Add line item</DialogTitle>
          <DialogDescription>
            This item is validated on its own before it joins the invoice.
          </DialogDescription>
        </DialogHeader>
        <form
          onSubmit={(e) => {
            e.stopPropagation()
            void form.handleSubmit(handleAdd)(e)
          }}
        >
          <FieldGroup>
            <Controller
              control={form.control}
              name="description"
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor="rhf-advanced-02-item-description">
                    Description
                  </FieldLabel>
                  <Input
                    {...field}
                    id="rhf-advanced-02-item-description"
                    placeholder="Design retainer"
                    aria-invalid={fieldState.invalid}
                  />
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
            <div className="flex gap-3">
              <Controller
                control={form.control}
                name="quantity"
                render={({ field, fieldState }) => (
                  <Field data-invalid={fieldState.invalid}>
                    <FieldLabel htmlFor="rhf-advanced-02-item-quantity">
                      Quantity
                    </FieldLabel>
                    <Input
                      {...field}
                      id="rhf-advanced-02-item-quantity"
                      type="number"
                      min="1"
                      step="1"
                      placeholder="1"
                      aria-invalid={fieldState.invalid}
                    />
                    {fieldState.invalid && (
                      <FieldError errors={[fieldState.error]} />
                    )}
                  </Field>
                )}
              />
              <Controller
                control={form.control}
                name="unitPrice"
                render={({ field, fieldState }) => (
                  <Field data-invalid={fieldState.invalid}>
                    <FieldLabel htmlFor="rhf-advanced-02-item-price">
                      Unit price (USD)
                    </FieldLabel>
                    <Input
                      {...field}
                      id="rhf-advanced-02-item-price"
                      type="number"
                      min="0"
                      step="0.01"
                      placeholder="0.00"
                      aria-invalid={fieldState.invalid}
                    />
                    {fieldState.invalid && (
                      <FieldError errors={[fieldState.error]} />
                    )}
                  </Field>
                )}
              />
            </div>
          </FieldGroup>
          <DialogFooter className="mt-6">
            <Button
              type="button"
              variant="outline"
              size="sm"
              onClick={() => setOpen(false)}
            >
              Cancel
            </Button>
            <Button type="submit" size="sm">
              Add item
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}
```
