# 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/tsf-advanced-02).

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

## Install

```bash
npx shadcn@latest add https://ui.flexnative.com/r/tsf-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: `@tanstack/react-form`, `lucide-react`, `sonner`, `zod`
- Registry items: `card`, `dialog`, `empty`, `field`, `input`, `button`

## Files

### `registry/forms/tanstack-form/advanced/tsf-advanced-02.tsx`

```tsx
'use client'

import { useForm } from '@tanstack/react-form'
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 './tsf-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.'),
})

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

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

export function TsfAdvanced02() {
  const form = useForm({
    defaultValues: {
      client: '',
      items: [] as LineItem[],
    },
    validators: { onSubmit: formSchema },
    onSubmit: async ({ value }) => {
      const total = value.items.reduce((sum, i) => sum + lineTotal(i), 0)
      toast.success('Invoice created', {
        description: `${value.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={(e) => {
          e.preventDefault()
          form.handleSubmit()
        }}
        className="flex flex-col gap-3"
      >
        <CardContent className="flex flex-col gap-6">
          <form.Field name="client">
            {(field) => {
              const isInvalid =
                field.state.meta.isTouched && !field.state.meta.isValid
              return (
                <Field data-invalid={isInvalid}>
                  <FieldLabel htmlFor={field.name}>Client</FieldLabel>
                  <Input
                    id={field.name}
                    name={field.name}
                    placeholder="Acme Inc."
                    aria-invalid={isInvalid}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                  />
                  {isInvalid && <FieldError errors={field.state.meta.errors} />}
                </Field>
              )
            }}
          </form.Field>

          <form.Field name="items" mode="array">
            {(itemsField) => {
              const items = itemsField.state.value
              const total = items.reduce((sum, i) => sum + lineTotal(i), 0)
              return (
                <div className="flex flex-col gap-3">
                  <div className="flex items-center justify-between">
                    <span className="text-sm font-medium">Line items</span>
                    {items.length > 0 && (
                      <span className="text-muted-foreground text-sm tabular-nums">
                        Total {money(total)}
                      </span>
                    )}
                  </div>

                  {items.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">
                      {items.map((item, index) => (
                        <li
                          key={index}
                          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={() => itemsField.removeValue(index)}
                            >
                              <Trash2Icon className="size-4" />
                            </Button>
                          </div>
                        </li>
                      ))}
                    </ul>
                  )}

                  <LineItemDialog
                    onAdd={(item) => itemsField.pushValue(item)}
                  />
                </div>
              )
            }}
          </form.Field>
        </CardContent>
        <form.Subscribe selector={(state) => state.values.items.length}>
          {(count) => (
            <CardFooter className="justify-end">
              <Button type="submit" size="sm" disabled={count === 0}>
                Create invoice
              </Button>
            </CardFooter>
          )}
        </form.Subscribe>
      </form>
    </Card>
  )
}
```

### `registry/forms/tanstack-form/advanced/tsf-advanced-02-line-item-dialog.tsx`

```tsx
'use client'

import { useState } from 'react'
import { useForm } from '@tanstack/react-form'
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({
    defaultValues: emptyItem,
    validators: { onSubmit: lineItemSchema },
    onSubmit: async ({ value }) => {
      onAdd(value)
      form.reset()
      setOpen(false)
    },
  })

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next)
        if (!next) form.reset()
      }}
    >
      <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.preventDefault()
            e.stopPropagation()
            form.handleSubmit()
          }}
        >
          <FieldGroup>
            <form.Field name="description">
              {(field) => {
                const isInvalid =
                  field.state.meta.isTouched && !field.state.meta.isValid
                return (
                  <Field data-invalid={isInvalid}>
                    <FieldLabel htmlFor={field.name}>Description</FieldLabel>
                    <Input
                      id={field.name}
                      name={field.name}
                      placeholder="Design retainer"
                      aria-invalid={isInvalid}
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                    />
                    {isInvalid && (
                      <FieldError errors={field.state.meta.errors} />
                    )}
                  </Field>
                )
              }}
            </form.Field>
            <div className="flex gap-3">
              <form.Field name="quantity">
                {(field) => {
                  const isInvalid =
                    field.state.meta.isTouched && !field.state.meta.isValid
                  return (
                    <Field data-invalid={isInvalid}>
                      <FieldLabel htmlFor={field.name}>Quantity</FieldLabel>
                      <Input
                        id={field.name}
                        name={field.name}
                        type="number"
                        min="1"
                        step="1"
                        placeholder="1"
                        aria-invalid={isInvalid}
                        value={field.state.value}
                        onBlur={field.handleBlur}
                        onChange={(e) => field.handleChange(e.target.value)}
                      />
                      {isInvalid && (
                        <FieldError errors={field.state.meta.errors} />
                      )}
                    </Field>
                  )
                }}
              </form.Field>
              <form.Field name="unitPrice">
                {(field) => {
                  const isInvalid =
                    field.state.meta.isTouched && !field.state.meta.isValid
                  return (
                    <Field data-invalid={isInvalid}>
                      <FieldLabel htmlFor={field.name}>
                        Unit price (USD)
                      </FieldLabel>
                      <Input
                        id={field.name}
                        name={field.name}
                        type="number"
                        min="0"
                        step="0.01"
                        placeholder="0.00"
                        aria-invalid={isInvalid}
                        value={field.state.value}
                        onBlur={field.handleBlur}
                        onChange={(e) => field.handleChange(e.target.value)}
                      />
                      {isInvalid && (
                        <FieldError errors={field.state.meta.errors} />
                      )}
                    </Field>
                  )
                }}
              </form.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>
  )
}
```
